From 65f4a6093ddf280804c242979a664a3abd96e835 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 18:43:55 +0100 Subject: [PATCH 01/59] adding the basic dom package --- src/packages/dom/van.test.ts | 311 ++++++++++++++++++ src/packages/dom/van.ts | 601 +++++++++++++++++++++++++++++++++++ 2 files changed, 912 insertions(+) create mode 100644 src/packages/dom/van.test.ts create mode 100644 src/packages/dom/van.ts diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts new file mode 100644 index 000000000..f55d5e726 --- /dev/null +++ b/src/packages/dom/van.test.ts @@ -0,0 +1,311 @@ +import van from "./van"; + +const { + a, + b, + button, + div, + h2, + i, + input, + li, + option, + p, + pre, + select, + span, + sup, + table, + tbody, + td, + th, + thead, + tr, + ul, +} = van.tags; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const waitMsForDerivations = 5; + +const createHiddenDom = () => { + const dom = div({ class: "hidden" }); + van.add(document.body, dom); + return dom; +}; + + +describe("van", () => { + describe("tag", () => { + it("should create basic tag", () => { + const dom = div( + p("👋Hello"), + ul(li("🗺️World"), li(a({ href: "https://vanjs.org/" }, "🍦VanJS"))) + ); + + expect(dom.outerHTML).toBe( + '

👋Hello

' + ); + }); + + it("should add onclick event", () => { + const dom = div( + button({ onclick: () => van.add(dom, p("Button clicked!")) }) + ); + dom.querySelector("button")!.click(); + expect(dom.outerHTML).toBe( + "

Button clicked!

" + ); + }); + + it("should escape html tags", () => { + expect(p("").outerHTML).toBe("

<input>

"); + expect(div("a && b").outerHTML).toBe("
a && b
"); + expect(div("").outerHTML).toBe( + "
<input a && b>
" + ); + }); + + it("should create nested tags", () => { + expect(ul([li("Item 1"), li("Item 2"), li("Item 3")]).outerHTML).toBe( + "" + ); + }); + + it("should create deep nested tags", () => { + expect(ul([[li("Item 1"), [li("Item 2")]], li("Item 3")]).outerHTML).toBe( + "" + ); + }); + + it("should create tags with null props", () => { + const dom = button({ onclick: null }); + expect(dom.onclick === null); + }); + + it("should connect props to state", async () => { + const href = van.state("http://example.com/"); + const dom = a({ href }, "Test Link"); + van.add(createHiddenDom(), dom); + expect(dom.href).toBe("http://example.com/"); + href.val = "https://vanjs.org/"; + await sleep(waitMsForDerivations); + expect(dom.href).toBe("https://vanjs.org/"); + }); + + it("should not change props when state is disconnected", async () => { + const href = van.state("http://example.com/"); + const dom = a({ href }, "Test Link"); + expect(dom.href).toBe("http://example.com/"); + href.val = "https://vanjs.org/"; + await sleep(waitMsForDerivations); + // href won't change as dom is not connected to document + expect(dom.href).toBe("http://example.com/"); + }); + + it("should change connect onclick handler with state", async () => { + const dom = div() + van.add(createHiddenDom(), dom) + // TODO: fix the any type here. It should ideally be an EventListener | null + const handler = van.state((() => van.add(dom, p("Button clicked!")))) + van.add(dom, button({ onclick: handler })) + dom.querySelector("button")!.click() + expect(dom.outerHTML).toBe("

Button clicked!

") + + handler.val = () => van.add(dom, div("Button clicked!")) + await sleep(waitMsForDerivations) + dom.querySelector("button")!.click() + expect(dom.outerHTML).toBe("

Button clicked!

Button clicked!
") + + handler.val = null + await sleep(waitMsForDerivations) + dom.querySelector("button")!.click() + expect(dom.outerHTML).toBe("

Button clicked!

Button clicked!
") + }); + + it('should not change onclick handler when state is disconnected', async () => { + const dom = div() + const handler = van.state(() => van.add(dom, p("Button clicked!"))) + van.add(dom, button({ onclick: handler })) + dom.querySelector("button")!.click() + expect(dom.outerHTML).toBe("

Button clicked!

") + + handler.val = () => van.add(dom, div("Button clicked!")) + await sleep(waitMsForDerivations) + dom.querySelector("button")!.click() + // The onclick handler won't change as dom is not connected to document, as a result, the

element will be added + expect(dom.outerHTML).toBe("

Button clicked!

Button clicked!

") + }); + + it("should update props from a derived state", async () => { + const host = van.state("example.com") + const path = van.state("/hello") + const dom = a({ href: () => `https://${host.val}${path.val}` }, "Test Link") + van.add(createHiddenDom(), dom) + expect(dom.href).toBe("https://example.com/hello") + host.val = "vanjs.org" + path.val = "/start" + await sleep(waitMsForDerivations) + expect(dom.href).toBe("https://vanjs.org/start") + }); + + it('should not update props from a disconnected derived state', async () => { + const host = van.state("example.com") + const path = van.state("/hello") + const dom = a({ href: () => `https://${host.val}${path.val}` }, "Test Link") + expect(dom.href).toBe("https://example.com/hello") + host.val = "vanjs.org" + path.val = "/start" + await sleep(waitMsForDerivations) + // href won't change as dom is not connected to document + expect(dom.href).toBe("https://example.com/hello") + }); + + it("should update props when partial state", async () => { + const host = van.state("example.com") + const path = "/hello" + const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link") + van.add(createHiddenDom(), dom) + expect(dom.href).toBe("https://example.com/hello") + host.val = "vanjs.org" + await sleep(waitMsForDerivations) + expect(dom.href).toBe("https://vanjs.org/hello") + }); + + it("should not update props when partial state is disconnected", async () => { + const host = van.state("example.com") + const path = "/hello" + const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link") + expect(dom.href).toBe("https://example.com/hello") + host.val = "vanjs.org" + await sleep(waitMsForDerivations) + // href won't change as dom is not connected to document + expect(dom.href).toBe("https://example.com/hello") + }); + + it("should render correctly when connected state throws an error", async () => { + const text = van.state("hello") + const dom = div( + div( + { + class: () => { + if (text.val === "fail") throw new Error() + return text.val + }, + "data-name": text, + }, + text, + ), + div( + { + class: () => { + if (text.val === "fail") throw new Error() + return text.val + }, + "data-name": text, + }, + text, + ), + ) + van.add(createHiddenDom(), dom) + expect(dom.outerHTML).toBe('
hello
hello
') + + text.val = "fail" + await sleep(waitMsForDerivations) + // The binding function for `class` property throws an error. + // We want to validate the `class` property won't be updated because of the error, + // but other properties and child nodes are updated as usual. + expect(dom.outerHTML).toBe('
fail
fail
') + }); + + it('should render correct when disconnected state throws an error', async () => { + const text = van.state("hello") + const dom = div( + div( + { + class: () => { + if (text.val === "fail") throw new Error() + return text.val + }, + "data-name": text, + }, + text, + ), + div( + { + class: () => { + if (text.val === "fail") throw new Error() + return text.val + }, + "data-name": text, + }, + text, + ), + ) + expect(dom.outerHTML).toBe('
hello
hello
') + + text.val = "fail" + await sleep(waitMsForDerivations) + // `dom` won't change as it's not connected to document + expect(dom.outerHTML).toBe('
hello
hello
') + }); + + it('should change and trigger onclick handler when state is connected', async () => { + const hiddenDom = createHiddenDom(); + const elementName = van.state("p") + van.add(hiddenDom, button({ + onclick: van.derive(() => { + const name = elementName.val + return name ? () => van.add(hiddenDom, van.tags[name]("Button clicked!")) : null + }), + })) + hiddenDom.querySelector("button")!.click() + expect(hiddenDom.innerHTML).toBe("

Button clicked!

") + + elementName.val = "div" + await sleep(waitMsForDerivations) + hiddenDom.querySelector("button")!.click() + expect(hiddenDom.innerHTML).toBe("

Button clicked!

Button clicked!
") + + elementName.val = "" + await sleep(waitMsForDerivations) + hiddenDom.querySelector("button")!.click() + expect(hiddenDom.innerHTML).toBe("

Button clicked!

Button clicked!
") + }); + + it("should not change onclick handler when state is disconnected", async () => { + const dom = div() + const elementName = van.state("p") + van.add(dom, button({ + onclick: van.derive(() => { + const name = elementName.val + return name ? () => van.add(dom, van.tags[name]("Button clicked!")) : null + }), + })) + dom.querySelector("button")!.click() + expect(dom.innerHTML).toBe("

Button clicked!

") + + elementName.val = "div" + await sleep(waitMsForDerivations) + // The onclick handler won't change as `dom` is not connected to document, + // as a result, the

element will be added. + dom.querySelector("button")!.click() + expect(dom.innerHTML).toBe("

Button clicked!

Button clicked!

") + }); + + it("should update data attributes when state is connected", async () => { + const lineNum = van.state(1) + const dom = div({ + "data-type": "line", + "data-id": lineNum, + "data-line": () => `line=${lineNum.val}`, + }, + "This is a test line", + ) + van.add(createHiddenDom(), dom) + expect(dom.outerHTML).toBe('
This is a test line
') + + lineNum.val = 3 + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe('
This is a test line
') + }); + }); +}); diff --git a/src/packages/dom/van.ts b/src/packages/dom/van.ts new file mode 100644 index 000000000..49a940e75 --- /dev/null +++ b/src/packages/dom/van.ts @@ -0,0 +1,601 @@ +/** + * A TypeScript and modified version of the VanJS project. + * Credits: https://github.com/vanjs-org/van & https://github.com/ge3224/van-ts + */ + +/** + * A type representing primitive JavaScript types. + */ +export type Primitive = string | number | boolean | bigint; + +/** + * A type representing a property value which can be a primitive, a function, + * or null. + */ +export type PropValue = Primitive | ((e: T) => void) | null; + +/** + * A type representing valid child DOM values. + */ +export type ValidChildDomValue = Primitive | Node | null | undefined; + +/** + * A type representing functions that generate DOM values. + */ +export type BindingFunc = + | ((dom?: Node) => ValidChildDomValue) + | ((dom?: Element) => Element); + +/** + * A type representing various possible child DOM values. + */ +export type ChildDom = + | ValidChildDomValue + | StateView + | BindingFunc + | readonly ChildDom[]; + +type ConnectedDom = { isConnected: number }; + +type Binding = { + f: BindingFunc; + _dom: HTMLElement | null | undefined; +}; + +type Listener = { + f: BindingFunc; + s: State; + _dom?: HTMLElement | null | undefined; +}; + +type Connectable = Listener | Binding; + +/** + * Interface representing a state object with various properties and bindings. + */ +export interface State { + val: T | undefined; + readonly oldVal: T | undefined; + rawVal: T | undefined; + _oldVal: T | undefined; + _bindings: Array; + _listeners: Array>; +} + +/** + * A type representing a read-only view of a `State` object. + */ +export type StateView = Readonly>; + +/** + * A type representing a value that can be either a `State` object or a direct + * value of type `T`. + */ +export type Val = State | T; + +/** + * A type representing a property value, a state view of a property value, or a + * function returning a property value. + */ +export type PropValueOrDerived = + | PropValue + | StateView + | (() => PropValue); + +/** + * A type representing partial props with known keys for a specific + * element type. + */ +export type Props = Record & { + class?: PropValueOrDerived; +}; + +export type PropsWithKnownKeys = Partial<{ + [K in keyof ElementType]: PropValueOrDerived; +}>; + +/** + * Represents a function type that constructs a tagged result using provided + * properties and children. + */ +export type TagFunc = ( + first?: (Props & PropsWithKnownKeys) | ChildDom, + ...rest: readonly ChildDom[] +) => Result; + +/** + * Interface representing dependencies with sets for getters and setters. + */ +interface Dependencies { + _getters: Set>; + _setters: Set>; +} + +/** + * A function type for searching property descriptors in a prototype chain. + */ +type PropertyDescriptorSearchFn = ( + proto: T +) => ReturnType | undefined; + +/** + * A function type for setting event listeners. + */ +type EventSetterFn = ( + v: EventListenerOrEventListenerObject, + oldV?: EventListenerOrEventListenerObject +) => void; + +/** + * A function type for setting property values. + */ +type PropSetterFn = (value: any) => void; + +/** + * Represents a function type for creating a namespace-specific collection of + * tag functions. + * + * @param {string} namespaceURI + * - The URI of the namespace for which the tag functions are being created. + * + * @returns {Readonly>>} + * - A readonly record of string keys to TagFunc functions, + * representing the collection of tag functions within the specified + * namespace. + */ +export type NamespaceFunction = ( + namespaceURI: string +) => Readonly>>; + +/** + * Represents a type for a collection of tag functions. + * + * This type includes: + * - A readonly record of string keys to TagFunc functions, enabling + * the creation of generic HTML elements. + * - Specific tag functions for each HTML element type as defined in + * HTMLElementTagNameMap, with the return type corresponding to the specific + * type of the HTML element (e.g., HTMLDivElement for 'div', + * HTMLAnchorElement for 'a'). + * + * Usage of this type allows for type-safe creation of HTML elements with + * specific properties and child elements. + */ +export type Tags = Readonly>> & { + [K in keyof HTMLElementTagNameMap]: TagFunc; +}; + +/** + * While VanJS prefers using `let` instead of `const` to help reduce bundle + * size, this project employs the `const` keyword. Bundle sizes are managed + * during TypeScript compilation and bundling, incorporating minification and + * tree shaking. + * + * The following are global variables used by VanJS to alias some builtin + * symbols and reduce the bundle size. + */ + +/** + * Set containing changed states. + */ +let changedStates: Set> | undefined; + +/** + * Set containing derived states. + */ +let derivedStates: Set>; + +/** + * Current dependencies object, containing getters and setters. + */ +let curDeps: Dependencies; + +/** + * Array containing current new derivations. + */ +let curNewDerives: Array; + +/** + * Set containing objects marked for garbage collection. + */ +let forGarbageCollection: Set | undefined; + +/** + * Alias for the built-in primitive value `undefined`. This variable is used to + * reduce bundle size. Since it is never initialized, its value equals + * `undefined`. During minification, variable names are shortened. + */ +let _undefined: undefined; + +/** + * Alias for the keyword `Object`. This is used to reduce bundle size during + * minification. + */ +const _object = Object; + +/** + * Alias for the keyword `document`. This is used to reduce bundle size during + * minification. + */ +const _document = document; + +/** + * A constant function returning the prototype of an object. + */ +const protoOf = _object.getPrototypeOf; + +/** + * Constant representing a DOM object that is always considered connected. + */ +const alwaysConnectedDom: ConnectedDom = { isConnected: 1 }; + +/** + * Constant representing the garbage collection cycle duration in milliseconds. + */ +const gcCycleInMs = 1000; + +/** + * Cache object for property setters. + */ +const propSetterCache: { [key: string]: ((v: T) => void) | 0 } = {}; + +/** + * Prototype of the `alwaysConnectedDom` object. + */ +const objProto = protoOf(alwaysConnectedDom); + +/** + * Prototype of the `Function` object. + */ +const funcProto = Function.prototype; + +/** + * Adds a state object to a set and schedules an associated function to be + * executed after a specified delay if the set is initially undefined. + */ +const addAndScheduleOnFirst = ( + set: Set> | undefined, + state: State, + fn: () => void, + waitMs?: number +): Set> => { + if (set === undefined) { + setTimeout(fn, waitMs); + set = new Set>(); + } + set.add(state); + return set; +}; + +/** + * Executes a function with a given argument and tracks dependencies during + * its execution. + */ +const runAndCaptureDependencies = ( + fn: Function, + deps: Dependencies, + arg: T +): T => { + let prevDeps = curDeps; + curDeps = deps; + + try { + return fn(arg); + } catch (e) { + console.error(e); + return arg; + } finally { + curDeps = prevDeps; + } +}; + +/** + * Filters an array of Connectable objects, returning only those whose `_dom` + * property is connected to the current document. + */ +const keepConnected = >(l: T[]): T[] => { + return l.filter((b) => b._dom?.isConnected); +}; + +/** + * Adds a state object to a collection that will be processed for + * garbage collection. + */ +const addForGarbageCollection = (discard: State): void => { + forGarbageCollection = addAndScheduleOnFirst( + forGarbageCollection, + discard, + () => { + if (forGarbageCollection) { + for (let s of forGarbageCollection) { + s._bindings = keepConnected(s._bindings); + s._listeners = keepConnected(s._listeners); + } + forGarbageCollection = _undefined; // Resets `forGarbageCollection` after processing + } + }, + gcCycleInMs + ); +}; + +/** + * Prototype for state objects, providing getter and setter for `val` and + * `oldVal`. + */ +const stateProto = { + get val() { + const state = this as State; + curDeps?._getters?.add(state); + return state.rawVal; + }, + + get oldVal() { + const state = this as State; + curDeps?._getters?.add(state); + return state._oldVal; + }, + + set val(v) { + const state = this as State; + curDeps?._setters?.add(state); + if (v !== state.rawVal) { + state.rawVal = v; + state._bindings.length + state._listeners.length + ? (derivedStates?.add(state), + (changedStates = addAndScheduleOnFirst( + changedStates, + state, + updateDoms + ))) + : (state._oldVal = v); + } + }, +}; + +/** + * Generates a property descriptor with preset characteristics for properties + * of a state object. + */ +const statePropertyDescriptor = (value: T): PropertyDescriptor => { + return { + writable: true, + configurable: true, + enumerable: true, + value: value, + }; +}; + +/** + * Creates a state object with optional initial value. The properties of the + * created object are configured to be enumerable, writable, and configurable. + * + * @template + * @param {T} [initVal] - Optional initial value for the state. + * @returns {State} A state object. + */ +const state = (initVal?: T): State => { + // In contrast to the VanJS implementation (above), where reducing the bundle + // size is a key priority, we use the `Object.create` method instead of the + // `Object.prototype.__proto__` accessor since the latter is no longer + // recommended. + // + // [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto) + return _object.create(stateProto, { + rawVal: statePropertyDescriptor(initVal), + _oldVal: statePropertyDescriptor(initVal), + _bindings: statePropertyDescriptor([]), + _listeners: statePropertyDescriptor([]), + }); +}; + +/** + * Binds a function to a DOM element, capturing its dependencies and updating + * the DOM as needed. + * + * @template T + * @param {Function} f - The function to bind. + * @param {T | undefined} [dom] - Optional DOM element or undefined. + * @returns {T} The resulting DOM element or text node.* + */ +const bind = (f: Function, dom?: T | undefined): T => { + let deps: Dependencies = { _getters: new Set(), _setters: new Set() }; + let binding: { [key: string]: any } = { f }; + let prevNewDerives = curNewDerives; + + curNewDerives = []; + let newDom = runAndCaptureDependencies(f, deps, dom); + + newDom = ((newDom ?? _document) as Node).nodeType + ? newDom + : new Text(newDom as string | undefined); + + for (let d of deps._getters) + deps._setters.has(d) || + (addForGarbageCollection(d as any), (d as any)._bindings.push(binding)); + for (let l of curNewDerives) l._dom = newDom; + curNewDerives = prevNewDerives; + return (binding._dom = newDom); +}; + +/** + * Derives a State based on a function and optional paremeters. + * + * @template T - The type of value returned by the derivation function. + * @param {() => T} f - The derivation that computes the value. + * @param {State} [s] - Optional initial State object to store the derived value. + * @param {ChildDom} [dom] - Optional DOM element or ChildDom to associate with the derivation. + * @returns {State} The State object containing the derived value and associated dependencies. + */ +const derive = (f: () => T, s?: State, dom?: ChildDom): State => { + s = s ?? state(); + let deps: Dependencies = { _getters: new Set(), _setters: new Set() }; + let listener: { [key: string]: any } = { f, s }; + listener._dom = dom ?? curNewDerives?.push(listener) ?? alwaysConnectedDom; + s.val = runAndCaptureDependencies(f, deps, s.rawVal); + for (let d of deps._getters) + deps._setters.has(d) || + (addForGarbageCollection(d), d._listeners.push(listener as Listener)); + return s; +}; + +/** + * Appends child elements to a DOM element and returns said DOM element. + * + * @param {Element} dom - The DOM element to which children will be added. + * @param {readonly ChildDom[]} children - An array of child elements or arrays of child elements to add. + * @returns {Element} The modified DOM element after adding all children. + */ +const add = (dom: Element, ...children: readonly ChildDom[]): Element => { + for (let c of (children as any).flat(Infinity)) { + const protoOfC = protoOf(c ?? 0); + const child = + protoOfC === stateProto + ? bind(() => c.val) + : protoOfC === funcProto + ? bind(c) + : c; + child != _undefined && dom.append(child); + } + return dom; +}; + +/** + * Creates a new DOM element with specified namespace, tag name, properties, + * and children. + */ +const tag = (ns: string | null, name: string, ...args: any): Element => { + const [props, ...children] = + protoOf(args[0] ?? 0) === objProto ? args : [{}, ...args]; + + const dom: Element | HTMLElement = ns + ? _document.createElementNS(ns, name) + : _document.createElement(name); + + for (let [k, v] of _object.entries(props)) { + const getDesc: PropertyDescriptorSearchFn = (proto: any) => + proto + ? _object.getOwnPropertyDescriptor(proto, k as PropertyKey) ?? + getDesc(protoOf(proto)) + : _undefined; + + const cacheKey = `${name},${k}`; + + const propSetter = + propSetterCache[cacheKey] ?? + (propSetterCache[cacheKey] = getDesc(protoOf(dom))?.set ?? 0); + + const setter: PropSetterFn | EventSetterFn = k.startsWith("on") + ? ( + v: EventListenerOrEventListenerObject, + oldV?: EventListenerOrEventListenerObject + ) => { + const event = k.slice(2); + if (oldV) dom.removeEventListener(event, oldV); + dom.addEventListener(event, v); + } + : propSetter + ? propSetter.bind(dom) + : dom.setAttribute.bind(dom, k); + + let protoOfV = protoOf(v ?? 0); + + k.startsWith("on") || + (protoOfV === funcProto && + ((v = derive(v as BindingFunc)), (protoOfV = stateProto))); + + protoOfV === stateProto + ? bind(() => (setter((v as any).val, (v as any)._oldVal), dom)) + : setter(v as EventListenerOrEventListenerObject); + } + + return add(dom, ...children); +}; + +/** + * Creates a proxy handler object for intercepting the 'get' property access + * operation. The handler wraps the access in a function call that binds the + * accessed property name along with an optional namespace. + */ +const proxyHandler = (namespace?: string): ProxyHandler => { + return { + get: (_: never, name: string) => + tag.bind(_undefined, namespace ?? null, name), + }; +}; + +/** + * Creates a Proxy-based Tags object with optional namespace functionality. + * + * @function + * @param {string} [namespace] - Optional namespace for organizing tags. + * @returns {Tags & NamespaceFunction} A Proxy object representing tags and namespaces. + */ +const tags = new Proxy( + (namespace?: string) => + new Proxy(tag, proxyHandler(namespace)) as NamespaceFunction, + proxyHandler() +) as Tags & NamespaceFunction; + +/** + * Updates a DOM element with a new DOM element, replacing the old one or + * removing it if newDom is null or undefined. + * + * @template T + * @param {T} dom - The current DOM element to update. + * @param {T} newDom - The new DOM element to replace with. + * @returns {void} + */ +const update = (dom: T, newDom: T): void => { + newDom + ? newDom !== dom && + (dom as HTMLElement).replaceWith(newDom as string | Node) + : (dom as HTMLElement).remove(); +}; + +/** + * Updates DOM elements based on changed and derived states. + */ +const updateDoms = () => { + let iter = 0, + derivedStatesArray = changedStates + ? [...changedStates].filter((s) => s.rawVal !== s._oldVal) + : []; + do { + derivedStates = new Set(); + for (let l of new Set( + derivedStatesArray.flatMap( + (s) => (s._listeners = keepConnected(s._listeners)) + ) + )) + derive(l.f, l.s, l._dom), (l._dom = _undefined); + } while (++iter < 100 && (derivedStatesArray = [...derivedStates]).length); + let changedStatesArray = changedStates + ? [...changedStates].filter((s) => s.rawVal !== s._oldVal) + : []; + changedStates = _undefined; + for (let b of new Set( + changedStatesArray.flatMap( + (s) => (s._bindings = keepConnected(s._bindings)) + ) + )) + b._dom && update(b._dom, bind(b.f, b._dom)), (b._dom = _undefined); + + for (let s of changedStatesArray) s._oldVal = s.rawVal; +}; + +/** + * Hydrates a DOM element with a function that updates its content. + * + * @template T + * @param {T} dom - The DOM node to hydrate. + * @param {(dom: T) => T | null | undefined} updateFn - The function to update the DOM node. + * @returns {T | void} The updated DOM node or void if update fails. + */ +const hydrate = ( + dom: T, + updateFn: (dom: T) => T | null | undefined +): T | void => { + return update(dom, bind(updateFn, dom)); +}; + +export default { add, tags, state, derive, hydrate }; From 003ab2af1174fb1bbd1b30511d8faa1c4c9ad2ea Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 19:06:27 +0100 Subject: [PATCH 02/59] tag tests --- src/packages/dom/van.test.ts | 201 +++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index f55d5e726..3515286c1 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -307,5 +307,206 @@ describe("van", () => { await sleep(waitMsForDerivations) expect(dom.outerHTML).toBe('
This is a test line
') }); + + it('should not update data attributes when state is disconnected', async () => { + const lineNum = van.state(1) + const dom = div({ + "data-type": "line", + "data-id": lineNum, + "data-line": () => `line=${lineNum.val}`, + }, + "This is a test line", + ) + expect(dom.outerHTML).toBe('
This is a test line
') + + lineNum.val = 3 + await sleep(waitMsForDerivations) + // Attributes won't change as dom is not connected to document + expect(dom.outerHTML).toBe('
This is a test line
') + }); + + it('should update readonly props when state is connected', async () => { + const form = van.state("form1") + const dom = button({ form }, "Button") + van.add(createHiddenDom(), dom) + expect(dom.outerHTML).toBe('') + + form.val = "form2" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe('') + + expect(input({ list: "datalist1" }).outerHTML).toBe('') + }) + + it('should not update readonly props when state is disconnected', async () => { + const form = van.state("form1") + const dom = button({ form }, "Button") + expect(dom.outerHTML).toBe('') + + form.val = "form2" + await sleep(waitMsForDerivations) + // Attributes won't change as dom is not connected to document + expect(dom.outerHTML).toBe('') + + expect(input({ list: "datalist1" }).outerHTML).toBe('') + }); + + it("should add custom event handler", () => { + const dom = div( + button({ oncustom: () => van.add(dom, p("Event triggered!")) }) + ); + dom.querySelector("button")!.dispatchEvent(new Event("custom")); + expect(dom.innerHTML).toBe("

Event triggered!

"); + }); + + it("should add custom event handler with state", async () => { + const hiddenDom = createHiddenDom(); + const oncustom = van.state(() => + van.add(hiddenDom, p("Handler 1 triggered!")) + ); + van.add(hiddenDom, button({ oncustom })); + hiddenDom.querySelector("button")!.dispatchEvent(new Event("custom")); + expect(hiddenDom.innerHTML).toBe( + "

Handler 1 triggered!

" + ); + + oncustom.val = () => van.add(hiddenDom, p("Handler 2 triggered!")); + await sleep(waitMsForDerivations); + hiddenDom.querySelector("button")!.dispatchEvent(new Event("custom")); + expect(hiddenDom.innerHTML).toBe( + "

Handler 1 triggered!

Handler 2 triggered!

" + ); + }); + + it('should add state derived custom event handler', async () => { + const handlerType = van.state(1); + const hiddenDom = createHiddenDom(); + van.add( + hiddenDom, + button({ + oncustom: van.derive(() => + handlerType.val === 1 + ? () => van.add(hiddenDom, p("Handler 1 triggered!")) + : () => van.add(hiddenDom, p("Handler 2 triggered!")) + ), + }) + ); + hiddenDom.querySelector("button")!.dispatchEvent(new Event("custom")); + expect(hiddenDom.innerHTML).toBe( + "

Handler 1 triggered!

" + ); + + handlerType.val = 2; + await sleep(waitMsForDerivations); + hiddenDom.querySelector("button")!.dispatchEvent(new Event("custom")); + expect(hiddenDom.innerHTML).toBe( + "

Handler 1 triggered!

Handler 2 triggered!

" + ); + }); + + it('should add child as connected state', async () => { + const hiddenDom = createHiddenDom(); + const line2 = van.state("Line 2") + const dom = div( + pre("Line 1"), + pre(line2), + pre("Line 3") + ) + van.add(hiddenDom, dom) + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + + line2.val = "Line 2: Extra Stuff" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
Line 1
Line 2: Extra Stuff
Line 3
") + + // null to remove text DOM + line2.val = null + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
Line 1
Line 3
") + + // Resetting the state won't bring the text DOM back + line2.val = "Line 2" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
Line 1
Line 3
") + }); + + it('should not update child when state is disconnected', async () => { + const line2 = van.state("Line 2") + const dom = div( + pre("Line 1"), + pre(line2), + pre("Line 3") + ) + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + + line2.val = "Line 2: Extra Stuff" + await sleep(waitMsForDerivations) + // Content won't change as dom is not connected to document + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + + line2.val = null + await sleep(waitMsForDerivations) + // Content won't change as dom is not connected to document + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + }); + + it('should not delete dom when child is a state', async () => { + const text = van.state("Text") + const dom = p(text) + van.add(createHiddenDom(), dom) + expect(dom.outerHTML).toBe("

Text

") + text.val = "" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

") + text.val = "Text" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Text

") + }); + + it('should create svg elements', () => { + const { circle, path, svg } = van.tags("http://www.w3.org/2000/svg"); + const dom = svg( + { width: "16px", viewBox: "0 0 50 50" }, + circle({ + cx: "25", + cy: "25", + r: "20", + stroke: "black", + "stroke-width": "2", + fill: "yellow", + }), + circle({ + cx: "16", + cy: "20", + r: "2", + stroke: "black", + "stroke-width": "2", + fill: "black", + }), + circle({ + cx: "34", + cy: "20", + r: "2", + stroke: "black", + "stroke-width": "2", + fill: "black", + }), + path({ + d: "M 15 30 Q 25 40, 35 30", + stroke: "black", + "stroke-width": "2", + fill: "transparent", + }) + ); + expect(dom.outerHTML).toBe( + '' + ); + }); + + it('should create math elements', () => { + const { math, mi, mn, mo, mrow, msup } = van.tags("http://www.w3.org/1998/Math/MathML") + const dom = math(msup(mi("e"), mrow(mi("i"), mi("π"))), mo("+"), mn("1"), mo("="), mn("0")) + expect(dom.outerHTML).toBe('eiπ+1=0') + }) }); }); From ebd5c7bc16e682b1bc8f7116f37a4d7f71957f28 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 19:17:40 +0100 Subject: [PATCH 03/59] add tests --- src/packages/dom/van.test.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index 3515286c1..28c0d5844 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -509,4 +509,128 @@ describe("van", () => { expect(dom.outerHTML).toBe('eiπ+1=0') }) }); + + describe('add', () => { + it("should add elements to the document", () => { + const dom = ul(); + expect(van.add(dom, li("Item 1"), li("Item 2"))).toBe(dom); + expect(dom.outerHTML).toBe("
  • Item 1
  • Item 2
"); + expect(van.add(dom, li("Item 3"), li("Item 4"), li("Item 5"))).toBe(dom); + expect(dom.outerHTML).toBe( + "
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
" + ); + // No-op if no children specified + expect(van.add(dom)).toBe(dom); + expect(dom.outerHTML).toBe( + "
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
" + ); + }); + + it('should add nested elements', () => { + const dom = ul() + expect(van.add(dom, [li("Item 1"), li("Item 2")])).toBe(dom) + expect(dom.outerHTML).toBe("
  • Item 1
  • Item 2
") + }); + + it('should add deeply nested elements', () => { + const dom = ul(); + van.add(dom, [li("Item 1"), li("Item 2")]); + // Deeply nested + expect(van.add(dom, [[li("Item 3"), [li("Item 4")]], li("Item 5")])).toBe(dom) + expect(dom.outerHTML).toBe("
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
") + }); + + it('should ignore empty array', () => { + const dom = ul() + van.add(dom, [li("Item 1"), li("Item 2")]); + // No-op if no children specified + expect(van.add(dom, [[[]]])).toBe(dom) + expect(dom.outerHTML).toBe("
  • Item 1
  • Item 2
") + }); + + it('should ignore null or undefined children', () => { + const dom = ul() + expect( + van.add(dom, li("Item 1"), li("Item 2"), undefined, li("Item 3"), null) + ).toBe(dom); + expect(dom.outerHTML).toBe( + "
  • Item 1
  • Item 2
  • Item 3
" + ); + expect( + van.add(dom, [ + li("Item 4"), + li("Item 5"), + undefined, + li("Item 6"), + null, + ]) + ).toBe(dom); + expect(dom.outerHTML).toBe( + "
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
  • Item 6
" + ); + }); + + it('should ignore nested null or undefined children', () => { + const dom = ul() + van.add(dom, li("Item 1"), li("Item 2"), undefined, li("Item 3"), null); + van.add(dom, [li("Item 4"), li("Item 5"), undefined, li("Item 6"), null]); + expect( + van.add(dom, [ + [undefined, li("Item 7"), null, [li("Item 8")]], + null, + li("Item 9"), + undefined, + ]) + ).toBe(dom); + expect(dom.outerHTML).toBe( + "
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
  • Item 6
  • Item 7
  • Item 8
  • Item 9
" + ); + }); + + it('should add children as connected state', async () => { + const hiddenDom = createHiddenDom(); + const line2 = van.state("Line 2") + expect(van.add(hiddenDom, + pre("Line 1"), + pre(line2), + pre("Line 3") + )).toBe(hiddenDom) + expect(hiddenDom.outerHTML).toBe('') + + line2.val = "Line 2: Extra Stuff" + await sleep(waitMsForDerivations) + expect(hiddenDom.outerHTML).toBe('') + + // null to remove text DOM + line2.val = null + await sleep(waitMsForDerivations) + expect(hiddenDom.outerHTML).toBe('') + + // Resetting the state won't bring the text DOM back + line2.val = "Line 2" + await sleep(waitMsForDerivations) + expect(hiddenDom.outerHTML).toBe('') + }); + + it('should not change children when state is disconnected', async () => { + const line2 = van.state("Line 2") + const dom = div() + expect(van.add(dom, + pre("Line 1"), + pre(line2), + pre("Line 3") + )).toBe(dom) + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + + line2.val = "Line 2: Extra Stuff" + await sleep(waitMsForDerivations) + // Content won't change as dom is not connected to document + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + + line2.val = null + await sleep(waitMsForDerivations) + // Content won't change as dom is not connected to document + expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") + }); + }); }); From f3740e24a1b4ce4e6a6e2b81f6a295b959b3c782 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 19:53:52 +0100 Subject: [PATCH 04/59] derived tests --- src/packages/dom/van.test.ts | 306 +++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index 28c0d5844..98a2701ec 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -633,4 +633,310 @@ describe("van", () => { expect(dom.outerHTML).toBe("
Line 1
Line 2
Line 3
") }); }); + + describe('state', () => { + it('should return the correct oldVal and val', async () => { + const hiddenDom = createHiddenDom(); + const s = van.state("State Version 1") + expect(s.val).toBe("State Version 1") + expect(s.oldVal).toBe("State Version 1") + + // If the state object doesn't have any bindings, we directly update the `oldVal` + s.val = "State Version 2" + expect(s.val).toBe("State Version 2") + expect(s.oldVal).toBe("State Version 2") + + van.add(hiddenDom, s) + // If the state object has some bindings, `oldVal` refers to its old value until DOM update completes + s.val = "State Version 3" + expect(s.val).toBe("State Version 3") + expect(s.oldVal).toBe("State Version 2") + await sleep(waitMsForDerivations) + expect(s.val).toBe("State Version 3") + expect(s.oldVal).toBe("State Version 3") + }); + + it('should not trigger derived states when rawVal is set', async () => { + const hiddenDom = createHiddenDom(); + const history: number[] = [] + const a = van.state(3), b = van.state(5) + const s = van.derive(() => a.rawVal! + b.val!) + van.derive(() => history.push(a.rawVal! + b.val!)) + + van.add(hiddenDom, + input({ type: "text", value: () => a.rawVal! + b.val! }), + p(() => a.rawVal! + b.val!) + ) + + await sleep(waitMsForDerivations) + expect(s.val).toBe(8) + expect(history).toStrictEqual([8]) + expect(hiddenDom.querySelector("input")!.value).toBe("8") + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8") + + // Changing the `val` of `a` won't trigger the derived states, side effects, state-derived + // properties and state-derived child nodes, as the value of `a` is accessed via `a.rawVal`. + ++a.val! + await sleep(waitMsForDerivations) + expect(s.val).toBe(8) + expect(history).toStrictEqual([8]) + expect(hiddenDom.querySelector("input")!.value).toBe("8") + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8") + + // Changing the `val` of `b` will trigger the derived states, side effects, state-derived + // properties and state-derived child nodes, as the value of `b` is accessed via `b.rawVal`. + ++b.val! + await sleep(waitMsForDerivations) + expect(s.val).toBe(10) + expect(history).toStrictEqual([8,10]) + expect(hiddenDom.querySelector("input")!.value).toBe("10") + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("10") + }); + }); + + describe('derive', () => { + it('should trigger callback when val changes', async () => { + const history: string[] = [] + const s = van.state("This") + van.derive(() => history.push(s.val!)) + expect(history).toStrictEqual(["This"]) + + s.val = "is" + await sleep(waitMsForDerivations) + expect(history).toStrictEqual(["This","is"]); + + s.val = "a" + await sleep(waitMsForDerivations) + expect(history).toStrictEqual(["This","is","a"]); + + s.val = "test" + await sleep(waitMsForDerivations) + expect(history).toStrictEqual(["This","is","a","test"]) + + s.val = "test" + await sleep(waitMsForDerivations) + expect(history).toStrictEqual(["This","is","a","test"]) + + s.val = "test2" + // "Test2" won't be added into `history` as `s` will be set to "test3" immediately + s.val = "test3" + await sleep(waitMsForDerivations) + expect(history).toStrictEqual(["This","is","a","test","test3"]) + }); + + it("should trigger derived state callback when val changes", async () => { + const numItems = van.state(0) + const items = van.derive(() => [...Array(numItems.val).keys()].map(i => `Item ${i + 1}`)) + const selectedIndex = van.derive(() => (items.val, 0)) + const selectedItem = van.derive(() => items.val![selectedIndex.val!]) + + numItems.val = 3 + await sleep(waitMsForDerivations) + expect(numItems.val).toBe(3) + expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3") + expect(selectedIndex.val).toBe(0) + expect(selectedItem.val).toBe("Item 1") + + selectedIndex.val = 2 + await sleep(waitMsForDerivations) + expect(selectedIndex.val).toBe(2) + expect(selectedItem.val).toBe("Item 3") + + numItems.val = 5 + await sleep(waitMsForDerivations) + expect(numItems.val).toBe(5) + expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3,Item 4,Item 5") + expect(selectedIndex.val).toBe(0) + expect(selectedItem.val).toBe("Item 1") + + selectedIndex.val = 3 + await sleep(waitMsForDerivations) + expect(selectedIndex.val).toBe(3) + expect(selectedItem.val).toBe("Item 4") + }); + + it('should trigger compute conditional derived state', async () => { + const cond = van.state(true) + const a = van.state(1), b = van.state(2), c = van.state(3), d = van.state(4) + let numEffectTriggered = 0 + const sum = van.derive(() => (++numEffectTriggered, cond.val ? a.val! + b.val! : c.val! + d.val!)) + + expect(sum.val).toBe(3) + expect(numEffectTriggered).toBe(1) + + a.val = 11 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(13) + expect(numEffectTriggered).toBe(2) + + b.val = 12 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(23) + expect(numEffectTriggered).toBe(3) + + // Changing c or d won't triggered the effect as they're not its current dependencies + c.val = 13 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(23) + expect(numEffectTriggered).toBe(3) + + d.val = 14 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(23) + expect(numEffectTriggered).toBe(3) + + cond.val = false + await sleep(waitMsForDerivations) + expect(sum.val).toBe(27) + expect(numEffectTriggered).toBe(4) + + c.val = 23 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(37) + expect(numEffectTriggered).toBe(5) + + d.val = 24 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(47) + expect(numEffectTriggered).toBe(6) + + // Changing a or b won't triggered the effect as they're not its current dependencies + a.val = 21 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(47) + expect(numEffectTriggered).toBe(6) + + b.val = 22 + await sleep(waitMsForDerivations) + expect(sum.val).toBe(47) + expect(numEffectTriggered).toBe(6) + }); + + it('should not change state when derive throws error', async () => { + const s0 = van.state(1) + const s1 = van.derive(() => s0.val! * 2) + const s2 = van.derive(() => { + if (s0.val! > 1) throw new Error() + return s0.val + }) + const s3 = van.derive(() => s0.val! * s0.val!) + + expect(s1.val).toBe(2) + expect(s2.val).toBe(1) + expect(s3.val).toBe(1) + + s0.val = 3 + await sleep(waitMsForDerivations) + // The derivation function for `s2` throws an error. + // We want to validate the `val` of `s2` remains the same because of the error, + // but other derived states are updated as usual. + expect(s1.val).toBe(6) + expect(s2.val).toBe(1) + expect(s3.val).toBe(9) + }); + + it('should update dom when derived state changes', async () => { + const hiddenDom = createHiddenDom(); + const CheckboxCounter = () => { + const checked = van.state(false), numChecked = van.state(0) + van.derive(() => { + if (checked.val) ++numChecked.val! + }) + + return div( + input({ type: "checkbox", checked, onclick: e => checked.val = ((e as Event).target as HTMLInputElement).checked }), + " Checked ", numChecked, " times. ", + button({ onclick: () => numChecked.val = 0 }, "Reset"), + ) + } + + van.add(hiddenDom, CheckboxCounter()) + + expect(hiddenDom.innerHTML).toBe('
Checked 0 times.
') + + hiddenDom.querySelector("input")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
Checked 1 times.
') + + hiddenDom.querySelector("input")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
Checked 1 times.
') + + hiddenDom.querySelector("input")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
Checked 2 times.
') + + hiddenDom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
Checked 0 times.
') + }) + + it('should batch derived state updates', async () => { + const a = van.state(3), b = van.state(5) + let numDerivations = 0 + const s = van.derive(() => { + ++numDerivations + return a.val! + b.val! + }) + + expect(s.val).toBe(8) + expect(numDerivations).toBe(1) + + // Both `a` and `b` will change. `s` will only be re-derived once + ++a.val!, ++b.val! + await sleep(waitMsForDerivations) + expect(s.val).toBe(10) + expect(numDerivations).toBe(2) + + // `a` will change, and then change back. No derivation will happen + ++a.val!, --a.val! + await sleep(waitMsForDerivations) + expect(s.val).toBe(10) + expect(numDerivations).toBe(2) + }); + + it('should batch multilayer derived state updates', async () => { + const hiddenDom = createHiddenDom(); + const a = van.state(1), b = van.derive(() => a.val! * a.val!) + const c = van.derive(() => b.val! * b.val!), d = van.derive(() => c.val! * c.val!) + + let numSDerived = 0, numSSquaredDerived = 0 + const s = van.derive(() => { + ++numSDerived + return a.val! + b.val! + c.val! + d.val! + }) + + van.add(hiddenDom, "a = ", a, " b = ", b, " c = ", c, " d = ", d, " s = ", s, + " s^2 = ", () => { + ++numSSquaredDerived + return s.val! * s.val! + } + ) + + expect(hiddenDom.innerHTML).toBe("a = 1 b = 1 c = 1 d = 1 s = 4 s^2 = 16") + expect(numSDerived).toBe(1) + expect(numSSquaredDerived).toBe(1) + + ++a.val! + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe("a = 2 b = 4 c = 16 d = 256 s = 278 s^2 = 77284") + // `s` is derived 4 times, triggered by `a`, `b`, `c`, `d`, respectively. + expect(numSDerived).toBe(5) + // `s^2` (the `s` derived Text node), is only derived once per one DOM update cycle. + expect(numSSquaredDerived).toBe(2) + }); + + it('should stop updating when there is a cycle in the derivation', async () => { + const a = van.state(1);; + const b = van.derive(() => a.val! + 1) + van.derive(() => a.val = b.val! + 1) + + // `a` and `b` are circular dependency. But derivations will stop after limited number of + // iterations. + ++a.val! + await sleep(waitMsForDerivations) + expect(a.val).toBe(104) + expect(b.val).toBe(103) + }); + }); }); From 9bf413806ad639a8d525bdc92c974e595c559a89 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 20:20:43 +0100 Subject: [PATCH 05/59] derived tests --- src/packages/dom/van.test.ts | 321 +++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index 98a2701ec..797b6cb7b 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -938,5 +938,326 @@ describe("van", () => { expect(a.val).toBe(104) expect(b.val).toBe(103) }); + + it('should dynamically update dom based on derived state', async () => { + const hiddenDom = createHiddenDom(); + const verticalPlacement = van.state(false) + const button1Text = van.state("Button 1"), button2Text = van.state("Button 2"), button3Text = van.state("Button 3") + + const domFunc = () => verticalPlacement.val ? div( + div(button(button1Text)), + div(button(button2Text)), + div(button(button3Text)), + ) : div( + button(button1Text), button(button2Text), button(button3Text), + ) + expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom) + + const dom = hiddenDom.firstChild + expect(dom.outerHTML).toBe("
") + button2Text.val = "Button 2: Extra" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
") + + verticalPlacement.val = true + await sleep(waitMsForDerivations) + + // dom is disconnected from the document thus it won't be updated + expect(dom.outerHTML).toBe("
") + expect((hiddenDom.firstChild).outerHTML).toBe("
") + button2Text.val = "Button 2: Extra Extra" + await sleep(waitMsForDerivations) + // Since dom is disconnected from document, its inner button won't be reactive to state changes + expect(dom.outerHTML).toBe("
") + expect((hiddenDom.firstChild).outerHTML).toBe("
") + }); + + it('should update dom based on conditional derived state', async () => { + const hiddenDom = createHiddenDom(); + const cond = van.state(true) + const button1 = van.state("Button 1"), button2 = van.state("Button 2") + const button3 = van.state("Button 3"), button4 = van.state("Button 4") + let numFuncCalled = 0 + const domFunc = () => (++numFuncCalled, cond.val ? + div(button(button1.val), button(button2.val)) : + div(button(button3.val), button(button4.val))) + expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom) + + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(1) + + button1.val = "Button 1-1" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(2) + + button2.val = "Button 2-1" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(3) + + // Changing button3 or button4 won't triggered the effect as they're not its current dependencies + button3.val = "Button 3-1" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(3) + + button4.val = "Button 4-1" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(3) + + cond.val = false + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(4) + + button3.val = "Button 3-2" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(5) + + button4.val = "Button 4-2" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(6) + + // Changing button1 or button2 won't triggered the effect as they're not its current dependencies + button1.val = "Button 1-2" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(6) + + button1.val = "Button 2-2" + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe("
") + expect(numFuncCalled).toBe(6) + }); + + it('should rearrange dom when state changes', async () => { + const hiddenDom = createHiddenDom(); + const numItems = van.state(0) + const items = van.derive(() => [...Array(numItems.val).keys()].map(i => `Item ${i + 1}`)) + const selectedIndex = van.derive(() => (items.val, 0)) + + const domFunc = (dom?: Element) => { + // If items aren't changed, we don't need to regenerate the entire dom + if (dom && items.val === items.oldVal) { + const itemDoms = dom.childNodes; + (itemDoms[selectedIndex.oldVal!]).classList.remove("selected"); + (itemDoms[selectedIndex.val!]).classList.add("selected") + return dom + } + + return ul( + items.val!.map((item: string, i: number) => li({ class: i === selectedIndex.val ? "selected" : "" }, item)) + ) + } + van.add(hiddenDom, domFunc) + + numItems.val = 3 + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
') + const rootDom1stIteration = hiddenDom.firstChild + + selectedIndex.val = 1 + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
') + // Items aren't changed, thus we don't need to regenerate the dom + expect(hiddenDom.firstChild!).toBe(rootDom1stIteration) + + numItems.val = 5 + await sleep(waitMsForDerivations) + // Items are changed, thus the dom for the list is regenerated + expect((hiddenDom.firstChild).outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
') + expect(hiddenDom.firstChild !== rootDom1stIteration) + // rootDom1stIteration is disconnected from the document and remain unchanged + expect(rootDom1stIteration.outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
') + const rootDom2ndIteration = hiddenDom.firstChild! + + selectedIndex.val = 2 + await sleep(waitMsForDerivations) + expect((hiddenDom.firstChild).outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
') + // Items aren't changed, thus we don't need to regenerate the dom + expect(hiddenDom.firstChild!).toBe(rootDom2ndIteration) + // rootDom1stIteration won't be updated as it has already been disconnected from the document + expect(rootDom1stIteration.outerHTML).toBe('
  • Item 1
  • Item 2
  • Item 3
') + }); + + it('should remove dom when it returns null', async () => { + const hiddenDom = createHiddenDom(); + const line1 = van.state("Line 1"), line2 = van.state("Line 2"), line3 = van.state("Line 3"), line4 = van.state(""), line5 = van.state(null) + + const dom = div( + () => line1.val === "" ? null : p(line1.val), + () => line2.val === "" ? null : p(line2.val), + p(line3), + // line4 won't appear in the DOM tree as its initial value is null + () => line4.val === "" ? null : p(line4.val), + // line5 won't appear in the DOM tree as its initial value is null + p(line5), + ) + van.add(hiddenDom, dom) + + expect(dom.outerHTML).toBe("

Line 1

Line 2

Line 3

") + // Delete Line 2 + line2.val = "" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

Line 3

") + + // Deleted dom won't be brought back, even the underlying state is changed back + line2.val = "Line 2" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

Line 3

") + + // Delete Line 3 + line3.val = null + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

") + + // Deleted dom won't be brought back, even the underlying state is changed back + line3.val = "Line 3" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

") + }) + + it('should remove dom when it returns undefined', async () => { + const hiddenDom = createHiddenDom(); + const line1 = van.state("Line 1"), line2 = van.state("Line 2"), line3 = van.state("Line 3"), line4 = van.state(""), line5 = van.state(undefined) + + const dom = div( + () => line1.val === "" ? null : p(line1.val), + () => line2.val === "" ? null : p(line2.val), + p(line3), + // line4 won't appear in the DOM tree as its initial value is null + () => line4.val === "" ? null : p(line4.val), + // line5 won't appear in the DOM tree as its initial value is null + p(line5), + ) + van.add(hiddenDom, dom) + + expect(dom.outerHTML).toBe("

Line 1

Line 2

Line 3

") + // Delete Line 2 + line2.val = "" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

Line 3

") + + // Deleted dom won't be brought back, even the underlying state is changed back + line2.val = "Line 2" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

Line 3

") + + // Delete Line 3 + line3.val = undefined + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

") + + // Deleted dom won't be brought back, even the underlying state is changed back + line3.val = "Line 3" + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("

Line 1

") + }); + + it('should not remove dom when it returns 0', async () => { + const hiddenDom = createHiddenDom(); + const state1 = van.state(0), state2 = van.state(1) + const dom = div(state1, () => 1 - state1.val!, state2, () => 1 - state2.val!) + van.add(hiddenDom, dom) + + expect(dom.outerHTML).toBe("
0110
") + + state1.val = 1, state2.val = 0 + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
1001
") + }) + + it('should update dom when primitive state changes', async () => { + const hiddenDom = createHiddenDom(); + const a = van.state(1), b = van.state(2), deleted = van.state(false) + const dom = div(() => deleted.val ? null : a.val! + b.val!) + expect(dom.outerHTML).toBe("
3
") + van.add(hiddenDom, dom) + + a.val = 6 + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
8
") + + b.val = 5 + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
11
") + + deleted.val = true + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
") + + // Deleted dom won't be brought back, even the underlying state is changed back + deleted.val = false + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe("
") + }) + + it('should not update when state is not connected', async () => { + const hiddenDom = createHiddenDom(); + const part1 = "👋Hello ", part2 = van.state("🗺️World") + + expect( + van.add( + hiddenDom, + () => `${part1}${part2.val}, from: ${part1}${part2.oldVal}` + ) + ).toBe(hiddenDom); + + const dom = hiddenDom.firstChild + expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") + expect(hiddenDom.innerHTML).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") + + part2.val = "🍦VanJS" + await sleep(waitMsForDerivations) + + // dom is disconnected from the document thus it won't be updated + expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") + expect(hiddenDom.innerHTML).toBe("👋Hello 🍦VanJS, from: 👋Hello 🗺️World") + }); + + it('should not update dom when oldVal is referenced', async () => { + const hiddenDom = createHiddenDom(); + const text = van.state("Old Text") + + expect(van.add(hiddenDom, () => `From: "${text.oldVal}" to: "${text.val}"`)).toBe(hiddenDom) + + const dom = hiddenDom.firstChild + expect(dom.textContent!).toBe('From: "Old Text" to: "Old Text"') + expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "Old Text"') + + text.val = "New Text" + await sleep(waitMsForDerivations) + + // dom is disconnected from the document thus it won't be updated + expect(dom.textContent).toBe('From: "Old Text" to: "Old Text"') + expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "New Text"') + }); + + it('should not update when state derived children throws error', async () => { + const hiddenDom = createHiddenDom(); + const num = van.state(0) + + expect(van.add(hiddenDom, + num, + () => { + if (num.val! > 0) throw new Error() + return span("ok") + }, + num + )).toBe(hiddenDom) + + expect(hiddenDom.innerHTML).toBe("0ok0") + + num.val = 1 + await sleep(waitMsForDerivations) + // The binding function 2nd child of hiddenDom throws an error. + // We want to validate the 2nd child won't be updated because of the error, + // but other DOM nodes are updated as usual + expect(hiddenDom.innerHTML).toBe("1ok1") + }); }); }); From afabeaf0a18725b975b6130565741ff01bc67d7a Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 20:27:42 +0100 Subject: [PATCH 06/59] hydrate tests --- src/packages/dom/van.test.ts | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index 797b6cb7b..e918e4c08 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -1260,4 +1260,81 @@ describe("van", () => { expect(hiddenDom.innerHTML).toBe("1ok1") }); }); + + describe('hydrate', () => { + it('should hydrate the given dom with the provided state', async () => { + const hiddenDom = createHiddenDom(); + const Counter = (init: number) => { + const counter = van.state(init) + return button({ "data-counter": counter, onclick: () => ++counter.val! }, + () => `Count: ${counter.val}`, + ) + } + // Static DOM before hydration + hiddenDom.innerHTML = Counter(5).outerHTML + + // Before hydration, the counter is not reactive + hiddenDom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('') + + van.hydrate(hiddenDom.querySelector("button")!, + (dom: HTMLElement) => Counter(Number(dom.getAttribute("data-counter")))) + + // After hydration, the counter is reactive + hiddenDom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('') + }); + + it('should remove dom when it returns null', async () => { + const hiddenDom = createHiddenDom(); + // Remove the DOM node upon hydration + van.add(hiddenDom, div()) + van.hydrate(hiddenDom.querySelector("div")!, () => null) + expect(hiddenDom.innerHTML).toBe("") + + // Remove the DOM node after the state update + van.add(hiddenDom, div()) + const s = van.state(1) + van.hydrate(hiddenDom.querySelector("div"), () => s.val === 1 ? pre() : null) + expect(hiddenDom.innerHTML).toBe("
")
+      s.val = 2
+      await sleep(waitMsForDerivations)
+      expect(hiddenDom.innerHTML).toBe("")
+  })
+
+  it('should remove dom when it returns undefined', async () => {
+  const hiddenDom = createHiddenDom();
+      // Remove the DOM node upon hydration
+      van.add(hiddenDom, div())
+      van.hydrate(hiddenDom.querySelector("div")!, () => undefined)
+      expect(hiddenDom.innerHTML).toBe("")
+
+      // Remove the DOM node after the state update
+      van.add(hiddenDom, div())
+      const s = van.state(1)
+      van.hydrate(hiddenDom.querySelector("div"), () => s.val === 1 ? pre() : undefined)
+      expect(hiddenDom.innerHTML).toBe("
")
+      s.val = 2
+      await sleep(waitMsForDerivations)
+      expect(hiddenDom.innerHTML).toBe("")
+  });
+
+  it('should not remove dom when it returns 0', async () => {
+  const hiddenDom = createHiddenDom();
+      van.add(hiddenDom, div(), div())
+
+      const s = van.state(0)
+      const [dom1, dom2] = hiddenDom.querySelectorAll("div")
+
+      van.hydrate(dom1, (() => s.val))
+      van.hydrate(dom2, (() => 1 - s.val!))
+      expect(hiddenDom.innerHTML).toBe("01")
+
+      s.val = 1
+      await sleep(waitMsForDerivations)
+      expect(hiddenDom.innerHTML).toBe("10")
+  });
+  });
 });

From 2aa0b882f927b692a33b3d2667a3e8da02ec6940 Mon Sep 17 00:00:00 2001
From: binrysearch 
Date: Sun, 28 Jul 2024 20:49:07 +0100
Subject: [PATCH 07/59] gc tests

---
 src/packages/dom/van.test.ts | 221 ++++++++++++++++++++++++++++++++++-
 1 file changed, 220 insertions(+), 1 deletion(-)

diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts
index e918e4c08..cf3ddc49f 100644
--- a/src/packages/dom/van.test.ts
+++ b/src/packages/dom/van.test.ts
@@ -1,4 +1,4 @@
-import van from "./van";
+import van, { State } from "./van";
 
 const {
   a,
@@ -1337,4 +1337,223 @@ describe("van", () => {
       expect(hiddenDom.innerHTML).toBe("10")
   });
   });
+
+  describe("gc", () => {
+    it("should bind basic state and derived state", async () => {
+      const hiddenDom = createHiddenDom();
+      const counter = van.state(0);
+      const bindingsPropKey = Object.entries(counter).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+
+      van.add(hiddenDom, () => span(`Counter: ${counter.val}`));
+
+      for (let i = 0; i < 100; ++i) ++counter.val!;
+      await sleep(waitMsForDerivations);
+
+      expect(hiddenDom.innerHTML).toBe("Counter: 100");
+      expect(Object(counter)[bindingsPropKey]).toHaveLength(2);
+    });
+
+    it("should clean up nested bindings", async () => {
+      const hiddenDom = createHiddenDom();
+      const renderPre = van.state(false),
+        text = van.state("Text");
+      const bindingsPropKey = Object.entries(renderPre).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+      const dom = div(() =>
+        (renderPre.val ? pre : div)(() => `--${text.val}--`)
+      );
+      van.add(hiddenDom, dom);
+
+      for (let i = 0; i < 20; ++i) {
+        renderPre.val = !renderPre.val;
+        await sleep(waitMsForDerivations);
+      }
+
+      // Wait until GC kicks in
+      await sleep(1000);
+
+      expect(Object(renderPre)[bindingsPropKey]).toHaveLength(1);
+      expect(Object(text)[bindingsPropKey]).toHaveLength(1);
+    });
+
+    it("should clean up conditional bindings", async () => {
+      const hiddenDom = createHiddenDom();
+      const cond = van.state(true);
+      const a = van.state(0),
+        b = van.state(0),
+        c = van.state(0),
+        d = van.state(0);
+      const bindingsPropKey = Object.entries(cond).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+      const dom = div(() => (cond.val ? a.val! + b.val! : c.val! + d.val!));
+      van.add(hiddenDom, dom);
+
+      const allStates: State[] = [cond, a, b, c, d];
+      for (let i = 0; i < 100; ++i) {
+        const randomState =
+          allStates[Math.floor(Math.random() * allStates.length)];
+        if (randomState === cond) randomState.val = !randomState.val;
+        else ++(>randomState).val!;
+        await sleep(waitMsForDerivations);
+      }
+
+      allStates.every((s) => {
+        expect(Object(s)[bindingsPropKey].length).toBeGreaterThanOrEqual(1);
+        expect(Object(s)[bindingsPropKey].length).toBeLessThanOrEqual(15);
+      });
+
+      // Wait until GC kicks in
+      await sleep(1000);
+      allStates.every((s) =>
+        expect(Object(s)[bindingsPropKey]).toHaveLength(1)
+      );
+    });
+
+    it("should correctly call derived state function", async () => {
+      const history: any[] = [];
+      const a = van.state(0);
+      const listenersPropKey = Object.entries(a).filter(([_, v]) =>
+        Array.isArray(v)
+      )[1][0];
+
+      van.derive(() => history.push(a.val));
+
+      for (let i = 0; i < 100; ++i) {
+        ++a.val!;
+        await sleep(waitMsForDerivations);
+      }
+
+      expect(history.length).toBe(101);
+      expect(Object(a)[listenersPropKey]).toHaveLength(2);
+    });
+
+    it("should clean up derived state function", async () => {
+      const hiddenDom = createHiddenDom();
+      const renderPre = van.state(false),
+        prefix = van.state("Prefix");
+      const bindingsPropKey = Object.entries(renderPre).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+      const listenersPropKey = Object.entries(renderPre).filter(([_, v]) =>
+        Array.isArray(v)
+      )[1][0];
+      const dom = div(() => {
+        const text = van.derive(() => `${prefix.val} - Suffix`);
+        return (renderPre.val ? pre : div)(() => `--${text.val}--`);
+      });
+      van.add(hiddenDom, dom);
+
+      for (let i = 0; i < 20; ++i) {
+        renderPre.val = !renderPre.val;
+        await sleep(waitMsForDerivations);
+      }
+
+      // Wait until GC kicks in
+      await sleep(1000);
+
+      expect(Object(renderPre)[bindingsPropKey]).toHaveLength(1);
+      expect(Object(prefix)[listenersPropKey]).toHaveLength(1);
+    });
+
+    it("should clean up derived state in props", async () => {
+      const hiddenDom = createHiddenDom();
+      const renderPre = van.state(false),
+        class1 = van.state(true);
+      const bindingsPropKey = Object.entries(renderPre).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+      const listenersPropKey = Object.entries(renderPre).filter(([_, v]) =>
+        Array.isArray(v)
+      )[1][0];
+      const dom = div(() =>
+        (renderPre.val ? pre : div)(
+          { class: () => (class1.val ? "class1" : "class2") },
+          "Text"
+        )
+      );
+      van.add(hiddenDom, dom);
+
+      for (let i = 0; i < 20; ++i) {
+        renderPre.val = !renderPre.val;
+        await sleep(waitMsForDerivations);
+      }
+
+      // Wait until GC kicks in
+      await sleep(1000);
+
+      expect(Object(renderPre)[bindingsPropKey]).toHaveLength(1);
+      expect(Object(class1)[listenersPropKey]).toHaveLength(1);
+    });
+
+    it("should clean up derived state as event handler", async () => {
+      const hiddenDom = createHiddenDom();
+      const renderPre = van.state(false),
+        handlerType = van.state(1);
+      const bindingsPropKey = Object.entries(renderPre).find(([_, v]) =>
+        Array.isArray(v)
+      )![0];
+      const listenersPropKey = Object.entries(renderPre).filter(([_, v]) =>
+        Array.isArray(v)
+      )[1][0];
+      const dom = div(() =>
+        (renderPre.val ? pre : div)(
+          button({
+            oncustom: van.derive(() =>
+              handlerType.val === 1
+                ? () => van.add(hiddenDom, p("Handler 1 triggered!"))
+                : () => van.add(hiddenDom, p("Handler 2 triggered!"))
+            ),
+          })
+        )
+      );
+      van.add(hiddenDom, dom);
+
+      for (let i = 0; i < 20; ++i) {
+        renderPre.val = !renderPre.val;
+        await sleep(waitMsForDerivations);
+      }
+
+      // Wait until GC kicks in
+      await sleep(1000);
+
+      expect(Object(renderPre)[bindingsPropKey]).toHaveLength(1);
+      expect(Object(handlerType)[listenersPropKey]).toHaveLength(1);
+    });
+
+    it("should clean up conditionally derived states", async () => {
+      const cond = van.state(true);
+      const a = van.state(0),
+        b = van.state(0),
+        c = van.state(0),
+        d = van.state(0);
+      const listenersPropKey = Object.entries(a).filter(([_, v]) =>
+        Array.isArray(v)
+      )[1][0];
+      van.derive(() => (cond.val ? a.val! + b.val! : c.val! + d.val!));
+
+      const allStates: State[] = [cond, a, b, c, d];
+      for (let i = 0; i < 100; ++i) {
+        const randomState =
+          allStates[Math.floor(Math.random() * allStates.length)];
+        if (randomState === cond) randomState.val = !randomState.val;
+        else ++(>randomState).val!;
+      }
+
+      allStates.every((s) => {
+        expect(Object(s)[listenersPropKey].length).toBeGreaterThanOrEqual(1);
+        expect(Object(s)[listenersPropKey].length).toBeLessThanOrEqual(10);
+      });
+
+      // Wait until GC kicks in
+      await sleep(1000);
+      allStates.every((s) => {
+        expect(Object(s)[listenersPropKey].length).toBeGreaterThanOrEqual(1);
+        expect(Object(s)[listenersPropKey].length).toBeLessThanOrEqual(4);
+      });
+    });
+  });
 });

From 557c645b18ba80a01e721af89de1fbfc86bd2183 Mon Sep 17 00:00:00 2001
From: binrysearch 
Date: Sun, 28 Jul 2024 21:56:37 +0100
Subject: [PATCH 08/59] e2e tests

---
 src/packages/dom/van.test.ts | 626 +++++++++++++++++++++++++++++++++++
 tests/jest/setup.ts          |   2 +
 tsconfig.json                |   4 +-
 3 files changed, 629 insertions(+), 3 deletions(-)

diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts
index cf3ddc49f..90dc45198 100644
--- a/src/packages/dom/van.test.ts
+++ b/src/packages/dom/van.test.ts
@@ -1556,4 +1556,630 @@ describe("van", () => {
       });
     });
   });
+
+  describe("e2e", () => {
+    it('should render Counter and update dom accordingly', async () => {
+      const hiddenDom = createHiddenDom();
+      const Counter = () => {
+        const counter = van.state(0)
+        return div(
+          div("❤️: ", counter),
+          button({ onclick: () => ++counter.val! }, "👍"),
+          button({ onclick: () => --counter.val! }, "👎"),
+        )
+      }
+
+      van.add(hiddenDom, Counter())
+
+      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 0")
+
+      const [incrementBtn, decrementBtn] = hiddenDom.getElementsByTagName("button")
+
+      incrementBtn.click()
+      await sleep(waitMsForDerivations)
+      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 1")
+
+      incrementBtn.click()
+      await sleep(waitMsForDerivations)
+      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 2")
+
+      decrementBtn.click()
+      await sleep(waitMsForDerivations)
+      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 1")
+    });
+
+    it('should render ul li', () => {
+      const List = ({ items }: { items: string[] }) =>
+        ul(items.map((it: any) => li(it)));
+      expect(List({ items: ["Item 1", "Item 2", "Item 3"] }).outerHTML).toBe(
+        "
  • Item 1
  • Item 2
  • Item 3
" + ); + }) + + it('should render table', () => { + const Table = ({ head, data }: + { head?: readonly string[], data: readonly (string | number)[][] }) => table( + head ? thead(tr(head.map(h => th(h)))) : [], + tbody(data.map(row => tr( + row.map(col => td(col)) + ))), + ) + + expect(Table({ + head: ["ID", "Name", "Country"], + data: [ + [1, "John Doe", "US"], + [2, "Jane Smith", "CA"], + [3, "Bob Johnson", "AU"], + ], + }).outerHTML).toBe("
IDNameCountry
1John DoeUS
2Jane SmithCA
3Bob JohnsonAU
") + + expect(Table({ + data: [ + [1, "John Doe", "US"], + [2, "Jane Smith", "CA"], + ], + }).outerHTML).toBe("
1John DoeUS
2Jane SmithCA
") + }) + + it('should render and update dom after changing state', async () => { + const hiddenDom = createHiddenDom(); + // Create a new state object with init value 1 + const counter = van.state(1) + + // Log whenever the value of the state is updated + van.derive(() => console.log(`Counter: ${counter.val}`)) + + // Derived state + const counterSquared = van.derive(() => counter.val! * counter.val!) + + // Used as a child node + const dom1 = div(counter) + + // Used as a property + const dom2 = input({ type: "number", value: counter, disabled: true }) + + // Used in a state-derived property + const dom3 = div({ style: () => `font-size: ${counter.val}em;` }, "Text") + + // Used in a state-derived child + const dom4 = div(counter, sup(2), () => ` = ${counterSquared.val}`) + + // Button to increment the value of the state + const incrementBtn = button({ onclick: () => ++counter.val! }, "Increment") + const resetBtn = button({ onclick: () => counter.val = 1 }, "Reset") + + van.add(hiddenDom, incrementBtn, resetBtn, dom1, dom2, dom3, dom4) + + expect(hiddenDom.innerHTML).toBe('
1
Text
12 = 1
') + expect(dom2.value).toBe("1") + + incrementBtn.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
2
Text
22 = 4
') + expect(dom2.value).toBe("2") + + incrementBtn.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
3
Text
32 = 9
') + expect(dom2.value).toBe("3") + + resetBtn.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('
1
Text
12 = 1
') + expect(dom2.value).toBe("1") + }) + + it('should update dom based on derived state', async () => { + const hiddenDom = createHiddenDom(); + const DerivedState = () => { + const text = van.state("VanJS") + const length = van.derive(() => text.val!.length) + return span( + "The length of ", + input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), + " is ", length, ".", + ) + } + + van.add(hiddenDom, DerivedState()) + const dom = (hiddenDom.firstChild) + expect(dom.outerHTML).toBe('The length of is 5.') + + const inputDom = dom.querySelector("input")! + inputDom.value = "Mini-Van" + inputDom.dispatchEvent(new Event("input")) + + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe('The length of is 8.') + }) + + it('should update props based on state', async () => { + const hiddenDom = createHiddenDom(); + const ConnectedProps = () => { + const text = van.state("") + return span( + input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), + input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), + ) + } + van.add(hiddenDom, ConnectedProps()) + + const [input1, input2] = hiddenDom.querySelectorAll("input") + input1.value += "123" + input1.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect(input1.value).toBe("123") + expect(input2.value).toBe("123") + + input2.value += "abc" + input2.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect(input1.value).toBe("123abc") + expect(input2.value).toBe("123abc") + }) + + it('should update css based on state', async () => { + const hiddenDom = createHiddenDom(); + const FontPreview = () => { + const size = van.state(16), color = van.state("black") + return span( + "Size: ", + input({ + type: "range", min: 10, max: 36, value: size, + oninput: (e: any) => size.val = Number((e.target).value) + }), + " Color: ", + select({ oninput: (e: any) => color.val = (e.target).value, value: color }, + ["black", "blue", "green", "red", "brown"].map(c => option({ value: c }, c)), + ), + span( + { + class: "preview", + style: () => `font-size: ${size.val}px; color: ${color.val};`, + }, " Hello 🍦VanJS"), + ) + } + van.add(hiddenDom, FontPreview()) + expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( + "font-size: 16px; color: black;" + ); + + hiddenDom.querySelector("input")!.value = "20" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( + "font-size: 20px; color: black;" + ); + + hiddenDom.querySelector("select")!.value = "blue" + hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( + "font-size: 20px; color: blue;" + ); + }); + + it('should bind event listener based on derived state', async () => { + const hiddenDom = createHiddenDom(); + const Counter = () => { + const counter = van.state(0) + const action = van.state("👍") + return span( + "❤️ ", counter, " ", + select({ oninput: (e: any) => action.val = e.target.value, value: action }, + option({ value: "👍" }, "👍"), option({ value: "👎" }, "👎"), + ), " ", + button({ + onclick: van.derive(() => action.val === "👍" ? + () => ++counter.val! : () => --counter.val!) + }, "Run"), + ) + } + + van.add(hiddenDom, Counter()) + const dom = (hiddenDom.firstChild) + expect(dom.outerHTML).toBe('❤️ 0 ') + + dom.querySelector("button")!.click() + dom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe('❤️ 2 ') + + dom.querySelector("select")!.value = "👎" + dom.querySelector("select")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + dom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe('❤️ 1 ') + }); + + it('should render nested ul li', async () => { + const hiddenDom = createHiddenDom(); + const SortedList = () => { + const items = van.state("a,b,c"), sortedBy = van.state("Ascending") + return span( + "Comma-separated list: ", + input({ + oninput: (e: any) => items.val = (e.target).value, + type: "text", value: items + }), " ", + select({ oninput: (e: any) => sortedBy.val = (e.target).value, value: sortedBy }, + option({ value: "Ascending" }, "Ascending"), + option({ value: "Descending" }, "Descending"), + ), + // A State-derived child node + () => sortedBy.val === "Ascending" ? + ul(items.val!.split(",").sort().map(i => li(i))) : + ul(items.val!.split(",").sort().reverse().map(i => li(i))), + ) + } + van.add(hiddenDom, SortedList()) + + hiddenDom.querySelector("input")!.value = "a,b,c,d" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( + "
  • a
  • b
  • c
  • d
") + + hiddenDom.querySelector("select")!.value = "Descending" + hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( + "
  • d
  • c
  • b
  • a
") + }) + + it('should render editable ul li', async() => { + const hiddenDom = createHiddenDom(); + const ListItem = ({ text }: {text: string}) => { + const deleted = van.state(false) + return () => deleted.val ? null : li( + text, + a({ onclick: () => deleted.val = true }, "❌"), + ) + } + + const EditableList = () => { + const listDom = ul() + const textDom = input({ type: "text" }) + return div( + textDom, " ", + button({ onclick: () => van.add(listDom, ListItem({ text: textDom.value })) }, "➕"), + listDom, + ) + } + van.add(hiddenDom, EditableList()) + + hiddenDom.querySelector("input")!.value = "abc" + hiddenDom.querySelector("button")!.click() + hiddenDom.querySelector("input")!.value = "123" + hiddenDom.querySelector("button")!.click() + hiddenDom.querySelector("input")!.value = "def" + hiddenDom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( + "") + + { + [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("123"))! + .querySelector("a")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( + "") + } + { + [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("abc"))! + .querySelector("a")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( + "") + } + { + [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("def"))! + .querySelector("a")!.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe("
    ") + } + }) + + it('should update dom based on polymorphic state', async () => { + const stateProto = Object.getPrototypeOf(van.state()) + const hiddenDom = createHiddenDom(); + let numYellowButtonClicked = 0 + + const val = (v: T | State | (() => T)) => { + const protoOfV = Object.getPrototypeOf(v ?? 0) + if (protoOfV === stateProto) return (>v).val + if (protoOfV === Function.prototype) return (<() => T>v)() + return v + } + + const Button = ({ + color, + text, + onclick, + }: { + color: State | string | (() => string); + text: State | string; + onclick: State<() => void> | (() => void); + }) => + button( + { style: () => `background-color: ${val(color)};`, onclick }, + text + ); + + const App = () => { + const colorState = van.state("green") + const textState = van.state("Turn Red") + + const turnRed = () => { + colorState.val = "red" + textState.val = "Turn Green" + onclickState.val = turnGreen + } + const turnGreen = () => { + colorState.val = "green" + textState.val = "Turn Red" + onclickState.val = turnRed + } + const onclickState = van.state(turnRed) + + const lightness = van.state(255) + + return span( + Button({ color: "yellow", text: "Click Me", onclick: () => ++numYellowButtonClicked }), " ", + Button({ color: colorState, text: textState, onclick: onclickState }), " ", + Button({ + color: () => `rgb(${lightness.val}, ${lightness.val}, ${lightness.val})`, + text: "Get Darker", + onclick: () => lightness.val = Math.max(lightness.val! - 10, 0), + }), + ) + } + + van.add(hiddenDom, App()) + + expect(hiddenDom.innerHTML).toBe(' ') + const [button1, button2, button3] = hiddenDom.querySelectorAll("button") + + button1.click() + expect(numYellowButtonClicked).toBe(1) + button1.click() + expect(numYellowButtonClicked).toBe(2) + + button2.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe(' ') + button2.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe(' ') + + button3.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe(' ') + button3.click() + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe(' ') + }); + + it('should update dom based on state', async () => { + const hiddenDom = createHiddenDom(); + const TurnBold = () => { + const vanJS = van.state("VanJS") + return span( + button({ onclick: () => vanJS.val = b("VanJS") }, "Turn Bold"), + " Welcome to ", vanJS, ". ", vanJS, " is awesome!" + ) + } + + van.add(hiddenDom, TurnBold()) + const dom = (hiddenDom.firstChild) + expect(dom.outerHTML).toBe(" Welcome to VanJS. VanJS is awesome!") + + dom.querySelector("button")!.click() + await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe(" Welcome to . VanJS is awesome!") + }) + + it('should batch updates', async () => { + const hiddenDom = createHiddenDom(); + const name = van.state("") + + const Name1 = () => { + const numRendered = van.state(0) + return div( + () => { + ++numRendered.val! + return name.val!.trim().length === 0 ? + p("Please enter your name") : + p("Hello ", b(name)) + }, + p(i("The

    element has been rendered ", numRendered, " time(s).")), + ) + } + + const Name2 = () => { + const numRendered = van.state(0) + const isNameEmpty = van.derive(() => name.val!.trim().length === 0) + return div( + () => { + ++numRendered.val! + return isNameEmpty.val ? + p("Please enter your name") : + p("Hello ", b(name)) + }, + p(i("The

    element has been rendered ", numRendered, " time(s).")), + ) + } + + van.add(hiddenDom, + p("Your name is: ", input({ type: "text", value: name, oninput: (e: any) => name.val = e.target.value })), + Name1(), + Name2(), + ) + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe( + '

    Your name is:

    Please enter your name

    The <p> element has been rendered 1 time(s).

    Please enter your name

    The <p> element has been rendered 1 time(s).

    ' + ); + + hiddenDom.querySelector("input")!.value = "T" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "Ta" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "Tao" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('

    Your name is:

    Hello Tao

    The <p> element has been rendered 4 time(s).

    Hello Tao

    The <p> element has been rendered 2 time(s).

    ') + + hiddenDom.querySelector("input")!.value = "" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations * 2) + expect(hiddenDom.innerHTML).toBe('

    Your name is:

    Please enter your name

    The <p> element has been rendered 5 time(s).

    Please enter your name

    The <p> element has been rendered 3 time(s).

    ') + + hiddenDom.querySelector("input")!.value = "X" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "Xi" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "Xin" + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) + await sleep(waitMsForDerivations) + + await sleep(waitMsForDerivations) + expect(hiddenDom.innerHTML).toBe('

    Your name is:

    Hello Xin

    The <p> element has been rendered 8 time(s).

    Hello Xin

    The <p> element has been rendered 4 time(s).

    ') + }) + + it("should hydrate the given element", async () => { + const stateProto = Object.getPrototypeOf(van.state()); + + const val = (v: T | State) => + Object.getPrototypeOf(v ?? 0) === stateProto ? (>v).val : v; + + const hiddenDom = createHiddenDom(); + const counterInit = 5; + + const Counter = ({ + id, + init = 0, + buttonStyle = "👍👎", + }: { + id?: string; + init?: number; + buttonStyle?: string | State; + }) => { + const { button, div } = van.tags; + + const [up, down] = [...Object(val(buttonStyle))]; + const counter = van.state(init); + return div( + { ...(id ? { id } : {}), "data-counter": counter }, + "❤️ ", + counter, + " ", + button({ onclick: () => ++counter.val! }, up), + button({ onclick: () => --counter.val! }, down) + ); + }; + const selectDom = select( + { value: "👆👇" }, + option("👆👇"), + option("👍👎"), + option("🔼🔽"), + option("⏫⏬"), + option("📈📉") + ); + const buttonStyle = van.state(selectDom.value); + selectDom.oninput = (e) => + (buttonStyle.val = (e!.target).value); + // Static DOM before hydration + hiddenDom.innerHTML = div( + h2("Basic Counter"), + Counter({ init: counterInit }), + h2("Styled Counter"), + p("Select the button style: ", selectDom), + Counter({ init: counterInit, buttonStyle }) + ).innerHTML; + + const clickBtns = async ( + dom: HTMLElement, + numUp: number, + numDown: number + ) => { + const [upBtn, downBtn] = [...dom.querySelectorAll("button")]; + for (let i = 0; i < numUp; ++i) { + upBtn.click(); + await sleep(waitMsForDerivations); + } + for (let i = 0; i < numDown; ++i) { + downBtn.click(); + await sleep(waitMsForDerivations); + } + }; + + const counterHTML = (counter: number, buttonStyle: string) => { + const [up, down] = [...buttonStyle]; + return div( + { "data-counter": counter }, + "❤️ ", + counter, + " ", + button(up), + button(down) + ).innerHTML; + }; + + // Before hydration, counters are not reactive + let [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + await clickBtns(basicCounter, 3, 1); + await clickBtns(styledCounter, 2, 5); + [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + expect(basicCounter.innerHTML).toBe(counterHTML(5, "👍👎")); + expect(styledCounter.innerHTML).toBe(counterHTML(5, "👆👇")); + + // Selecting a new button style won't change the actual buttons + selectDom.value = "🔼🔽"; + selectDom.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + expect(styledCounter.innerHTML).toBe(counterHTML(5, "👆👇")); + selectDom.value = "👆👇"; + selectDom.dispatchEvent(new Event("input")); + + van.hydrate(basicCounter, (dom) => + Counter({ + id: "basic-counter", + init: Number(dom.getAttribute("data-counter")), + }) + ); + van.hydrate(styledCounter, (dom) => + Counter({ + id: "styled-counter", + init: Number(dom.getAttribute("data-counter")), + buttonStyle: buttonStyle, + }) + ); + + // After hydration, counters are reactive + [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + await clickBtns(basicCounter, 3, 1); + await clickBtns(styledCounter, 2, 5); + [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + expect(basicCounter.innerHTML).toBe(counterHTML(7, "👍👎")); + expect(styledCounter.innerHTML).toBe(counterHTML(2, "👆👇")); + + // Selecting a new button style will change the actual buttons + const prevStyledCounter = styledCounter; + selectDom.value = "🔼🔽"; + selectDom.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + [basicCounter, styledCounter] = hiddenDom.querySelectorAll("div"); + expect(styledCounter.innerHTML).toBe(counterHTML(2, "🔼🔽")); + expect(styledCounter !== prevStyledCounter); + }); + }) }); diff --git a/tests/jest/setup.ts b/tests/jest/setup.ts index d151094d3..97fc99beb 100644 --- a/tests/jest/setup.ts +++ b/tests/jest/setup.ts @@ -10,4 +10,6 @@ const dom = new JSDOM(); global.document = dom.window.document; global.window = dom.window; global.Element = dom.window.Element; +global.Text = dom.window.Text; +global.Event = dom.window.Event; global.SVGElement = dom.window.SVGElement; diff --git a/tsconfig.json b/tsconfig.json index 7588f6b1f..7dcf0bff4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,8 @@ { "compilerOptions": { - "target": "es5", + "target": "es2015", "allowSyntheticDefaultImports": true, "allowJs": true, - "resolveJsonModule": true, - "importHelpers": true, "alwaysStrict": true, "sourceMap": true, "forceConsistentCasingInFileNames": true, From 9c7a2ffbe3444a409e3151cf1a90b7e81aff1b7c Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 28 Jul 2024 21:57:30 +0100 Subject: [PATCH 09/59] prettier --- src/packages/dom/van.test.ts | 2606 ++++++++++++++++++++-------------- src/packages/dom/van.ts | 8 +- 2 files changed, 1505 insertions(+), 1109 deletions(-) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/van.test.ts index 90dc45198..27f46b8aa 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/van.test.ts @@ -32,7 +32,6 @@ const createHiddenDom = () => { return dom; }; - describe("van", () => { describe("tag", () => { it("should create basic tag", () => { @@ -102,253 +101,311 @@ describe("van", () => { }); it("should change connect onclick handler with state", async () => { - const dom = div() - van.add(createHiddenDom(), dom) + const dom = div(); + van.add(createHiddenDom(), dom); // TODO: fix the any type here. It should ideally be an EventListener | null - const handler = van.state((() => van.add(dom, p("Button clicked!")))) - van.add(dom, button({ onclick: handler })) - dom.querySelector("button")!.click() - expect(dom.outerHTML).toBe("

    Button clicked!

    ") - - handler.val = () => van.add(dom, div("Button clicked!")) - await sleep(waitMsForDerivations) - dom.querySelector("button")!.click() - expect(dom.outerHTML).toBe("

    Button clicked!

    Button clicked!
    ") - - handler.val = null - await sleep(waitMsForDerivations) - dom.querySelector("button")!.click() - expect(dom.outerHTML).toBe("

    Button clicked!

    Button clicked!
    ") - }); - - it('should not change onclick handler when state is disconnected', async () => { - const dom = div() - const handler = van.state(() => van.add(dom, p("Button clicked!"))) - van.add(dom, button({ onclick: handler })) - dom.querySelector("button")!.click() - expect(dom.outerHTML).toBe("

    Button clicked!

    ") - - handler.val = () => van.add(dom, div("Button clicked!")) - await sleep(waitMsForDerivations) - dom.querySelector("button")!.click() + const handler = van.state(( + (() => van.add(dom, p("Button clicked!"))) + )); + van.add(dom, button({ onclick: handler })); + dom.querySelector("button")!.click(); + expect(dom.outerHTML).toBe( + "

    Button clicked!

    " + ); + + handler.val = () => van.add(dom, div("Button clicked!")); + await sleep(waitMsForDerivations); + dom.querySelector("button")!.click(); + expect(dom.outerHTML).toBe( + "

    Button clicked!

    Button clicked!
    " + ); + + handler.val = null; + await sleep(waitMsForDerivations); + dom.querySelector("button")!.click(); + expect(dom.outerHTML).toBe( + "

    Button clicked!

    Button clicked!
    " + ); + }); + + it("should not change onclick handler when state is disconnected", async () => { + const dom = div(); + const handler = van.state(() => van.add(dom, p("Button clicked!"))); + van.add(dom, button({ onclick: handler })); + dom.querySelector("button")!.click(); + expect(dom.outerHTML).toBe( + "

    Button clicked!

    " + ); + + handler.val = () => van.add(dom, div("Button clicked!")); + await sleep(waitMsForDerivations); + dom.querySelector("button")!.click(); // The onclick handler won't change as dom is not connected to document, as a result, the

    element will be added - expect(dom.outerHTML).toBe("

    Button clicked!

    Button clicked!

    ") + expect(dom.outerHTML).toBe( + "

    Button clicked!

    Button clicked!

    " + ); }); it("should update props from a derived state", async () => { - const host = van.state("example.com") - const path = van.state("/hello") - const dom = a({ href: () => `https://${host.val}${path.val}` }, "Test Link") - van.add(createHiddenDom(), dom) - expect(dom.href).toBe("https://example.com/hello") - host.val = "vanjs.org" - path.val = "/start" - await sleep(waitMsForDerivations) - expect(dom.href).toBe("https://vanjs.org/start") - }); - - it('should not update props from a disconnected derived state', async () => { - const host = van.state("example.com") - const path = van.state("/hello") - const dom = a({ href: () => `https://${host.val}${path.val}` }, "Test Link") - expect(dom.href).toBe("https://example.com/hello") - host.val = "vanjs.org" - path.val = "/start" - await sleep(waitMsForDerivations) + const host = van.state("example.com"); + const path = van.state("/hello"); + const dom = a( + { href: () => `https://${host.val}${path.val}` }, + "Test Link" + ); + van.add(createHiddenDom(), dom); + expect(dom.href).toBe("https://example.com/hello"); + host.val = "vanjs.org"; + path.val = "/start"; + await sleep(waitMsForDerivations); + expect(dom.href).toBe("https://vanjs.org/start"); + }); + + it("should not update props from a disconnected derived state", async () => { + const host = van.state("example.com"); + const path = van.state("/hello"); + const dom = a( + { href: () => `https://${host.val}${path.val}` }, + "Test Link" + ); + expect(dom.href).toBe("https://example.com/hello"); + host.val = "vanjs.org"; + path.val = "/start"; + await sleep(waitMsForDerivations); // href won't change as dom is not connected to document - expect(dom.href).toBe("https://example.com/hello") + expect(dom.href).toBe("https://example.com/hello"); }); it("should update props when partial state", async () => { - const host = van.state("example.com") - const path = "/hello" - const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link") - van.add(createHiddenDom(), dom) - expect(dom.href).toBe("https://example.com/hello") - host.val = "vanjs.org" - await sleep(waitMsForDerivations) - expect(dom.href).toBe("https://vanjs.org/hello") + const host = van.state("example.com"); + const path = "/hello"; + const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link"); + van.add(createHiddenDom(), dom); + expect(dom.href).toBe("https://example.com/hello"); + host.val = "vanjs.org"; + await sleep(waitMsForDerivations); + expect(dom.href).toBe("https://vanjs.org/hello"); }); it("should not update props when partial state is disconnected", async () => { - const host = van.state("example.com") - const path = "/hello" - const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link") - expect(dom.href).toBe("https://example.com/hello") - host.val = "vanjs.org" - await sleep(waitMsForDerivations) + const host = van.state("example.com"); + const path = "/hello"; + const dom = a({ href: () => `https://${host.val}${path}` }, "Test Link"); + expect(dom.href).toBe("https://example.com/hello"); + host.val = "vanjs.org"; + await sleep(waitMsForDerivations); // href won't change as dom is not connected to document - expect(dom.href).toBe("https://example.com/hello") + expect(dom.href).toBe("https://example.com/hello"); }); it("should render correctly when connected state throws an error", async () => { - const text = van.state("hello") + const text = van.state("hello"); const dom = div( div( { class: () => { - if (text.val === "fail") throw new Error() - return text.val + if (text.val === "fail") throw new Error(); + return text.val; }, "data-name": text, }, - text, + text ), div( { class: () => { - if (text.val === "fail") throw new Error() - return text.val + if (text.val === "fail") throw new Error(); + return text.val; }, "data-name": text, }, - text, - ), - ) - van.add(createHiddenDom(), dom) - expect(dom.outerHTML).toBe('
    hello
    hello
    ') + text + ) + ); + van.add(createHiddenDom(), dom); + expect(dom.outerHTML).toBe( + '
    hello
    hello
    ' + ); - text.val = "fail" - await sleep(waitMsForDerivations) + text.val = "fail"; + await sleep(waitMsForDerivations); // The binding function for `class` property throws an error. // We want to validate the `class` property won't be updated because of the error, // but other properties and child nodes are updated as usual. - expect(dom.outerHTML).toBe('
    fail
    fail
    ') + expect(dom.outerHTML).toBe( + '
    fail
    fail
    ' + ); }); - it('should render correct when disconnected state throws an error', async () => { - const text = van.state("hello") + it("should render correct when disconnected state throws an error", async () => { + const text = van.state("hello"); const dom = div( div( { class: () => { - if (text.val === "fail") throw new Error() - return text.val + if (text.val === "fail") throw new Error(); + return text.val; }, "data-name": text, }, - text, + text ), div( { class: () => { - if (text.val === "fail") throw new Error() - return text.val + if (text.val === "fail") throw new Error(); + return text.val; }, "data-name": text, }, - text, - ), - ) - expect(dom.outerHTML).toBe('
    hello
    hello
    ') + text + ) + ); + expect(dom.outerHTML).toBe( + '
    hello
    hello
    ' + ); - text.val = "fail" - await sleep(waitMsForDerivations) + text.val = "fail"; + await sleep(waitMsForDerivations); // `dom` won't change as it's not connected to document - expect(dom.outerHTML).toBe('
    hello
    hello
    ') + expect(dom.outerHTML).toBe( + '
    hello
    hello
    ' + ); }); - it('should change and trigger onclick handler when state is connected', async () => { + it("should change and trigger onclick handler when state is connected", async () => { const hiddenDom = createHiddenDom(); - const elementName = van.state("p") - van.add(hiddenDom, button({ - onclick: van.derive(() => { - const name = elementName.val - return name ? () => van.add(hiddenDom, van.tags[name]("Button clicked!")) : null - }), - })) - hiddenDom.querySelector("button")!.click() - expect(hiddenDom.innerHTML).toBe("

    Button clicked!

    ") + const elementName = van.state("p"); + van.add( + hiddenDom, + button({ + onclick: van.derive(() => { + const name = elementName.val; + return name + ? () => van.add(hiddenDom, van.tags[name]("Button clicked!")) + : null; + }), + }) + ); + hiddenDom.querySelector("button")!.click(); + expect(hiddenDom.innerHTML).toBe( + "

    Button clicked!

    " + ); - elementName.val = "div" - await sleep(waitMsForDerivations) - hiddenDom.querySelector("button")!.click() - expect(hiddenDom.innerHTML).toBe("

    Button clicked!

    Button clicked!
    ") + elementName.val = "div"; + await sleep(waitMsForDerivations); + hiddenDom.querySelector("button")!.click(); + expect(hiddenDom.innerHTML).toBe( + "

    Button clicked!

    Button clicked!
    " + ); - elementName.val = "" - await sleep(waitMsForDerivations) - hiddenDom.querySelector("button")!.click() - expect(hiddenDom.innerHTML).toBe("

    Button clicked!

    Button clicked!
    ") + elementName.val = ""; + await sleep(waitMsForDerivations); + hiddenDom.querySelector("button")!.click(); + expect(hiddenDom.innerHTML).toBe( + "

    Button clicked!

    Button clicked!
    " + ); }); - + it("should not change onclick handler when state is disconnected", async () => { - const dom = div() - const elementName = van.state("p") - van.add(dom, button({ - onclick: van.derive(() => { - const name = elementName.val - return name ? () => van.add(dom, van.tags[name]("Button clicked!")) : null - }), - })) - dom.querySelector("button")!.click() - expect(dom.innerHTML).toBe("

    Button clicked!

    ") + const dom = div(); + const elementName = van.state("p"); + van.add( + dom, + button({ + onclick: van.derive(() => { + const name = elementName.val; + return name + ? () => van.add(dom, van.tags[name]("Button clicked!")) + : null; + }), + }) + ); + dom.querySelector("button")!.click(); + expect(dom.innerHTML).toBe("

    Button clicked!

    "); - elementName.val = "div" - await sleep(waitMsForDerivations) + elementName.val = "div"; + await sleep(waitMsForDerivations); // The onclick handler won't change as `dom` is not connected to document, // as a result, the

    element will be added. - dom.querySelector("button")!.click() - expect(dom.innerHTML).toBe("

    Button clicked!

    Button clicked!

    ") + dom.querySelector("button")!.click(); + expect(dom.innerHTML).toBe( + "

    Button clicked!

    Button clicked!

    " + ); }); it("should update data attributes when state is connected", async () => { - const lineNum = van.state(1) - const dom = div({ - "data-type": "line", - "data-id": lineNum, - "data-line": () => `line=${lineNum.val}`, - }, - "This is a test line", - ) - van.add(createHiddenDom(), dom) - expect(dom.outerHTML).toBe('
    This is a test line
    ') - - lineNum.val = 3 - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe('
    This is a test line
    ') - }); - - it('should not update data attributes when state is disconnected', async () => { - const lineNum = van.state(1) - const dom = div({ - "data-type": "line", - "data-id": lineNum, - "data-line": () => `line=${lineNum.val}`, - }, - "This is a test line", - ) - expect(dom.outerHTML).toBe('
    This is a test line
    ') - - lineNum.val = 3 - await sleep(waitMsForDerivations) + const lineNum = van.state(1); + const dom = div( + { + "data-type": "line", + "data-id": lineNum, + "data-line": () => `line=${lineNum.val}`, + }, + "This is a test line" + ); + van.add(createHiddenDom(), dom); + expect(dom.outerHTML).toBe( + '
    This is a test line
    ' + ); + + lineNum.val = 3; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + '
    This is a test line
    ' + ); + }); + + it("should not update data attributes when state is disconnected", async () => { + const lineNum = van.state(1); + const dom = div( + { + "data-type": "line", + "data-id": lineNum, + "data-line": () => `line=${lineNum.val}`, + }, + "This is a test line" + ); + expect(dom.outerHTML).toBe( + '
    This is a test line
    ' + ); + + lineNum.val = 3; + await sleep(waitMsForDerivations); // Attributes won't change as dom is not connected to document - expect(dom.outerHTML).toBe('
    This is a test line
    ') + expect(dom.outerHTML).toBe( + '
    This is a test line
    ' + ); }); - it('should update readonly props when state is connected', async () => { - const form = van.state("form1") - const dom = button({ form }, "Button") - van.add(createHiddenDom(), dom) - expect(dom.outerHTML).toBe('') + it("should update readonly props when state is connected", async () => { + const form = van.state("form1"); + const dom = button({ form }, "Button"); + van.add(createHiddenDom(), dom); + expect(dom.outerHTML).toBe(''); - form.val = "form2" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe('') + form.val = "form2"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe(''); - expect(input({ list: "datalist1" }).outerHTML).toBe('') - }) + expect(input({ list: "datalist1" }).outerHTML).toBe( + '' + ); + }); - it('should not update readonly props when state is disconnected', async () => { - const form = van.state("form1") - const dom = button({ form }, "Button") - expect(dom.outerHTML).toBe('') + it("should not update readonly props when state is disconnected", async () => { + const form = van.state("form1"); + const dom = button({ form }, "Button"); + expect(dom.outerHTML).toBe(''); - form.val = "form2" - await sleep(waitMsForDerivations) + form.val = "form2"; + await sleep(waitMsForDerivations); // Attributes won't change as dom is not connected to document - expect(dom.outerHTML).toBe('') + expect(dom.outerHTML).toBe(''); - expect(input({ list: "datalist1" }).outerHTML).toBe('') + expect(input({ list: "datalist1" }).outerHTML).toBe( + '' + ); }); it("should add custom event handler", () => { @@ -378,7 +435,7 @@ describe("van", () => { ); }); - it('should add state derived custom event handler', async () => { + it("should add state derived custom event handler", async () => { const handlerType = van.state(1); const hiddenDom = createHiddenDom(); van.add( @@ -404,66 +461,72 @@ describe("van", () => { ); }); - it('should add child as connected state', async () => { + it("should add child as connected state", async () => { const hiddenDom = createHiddenDom(); - const line2 = van.state("Line 2") - const dom = div( - pre("Line 1"), - pre(line2), - pre("Line 3") - ) - van.add(hiddenDom, dom) - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") + const line2 = van.state("Line 2"); + const dom = div(pre("Line 1"), pre(line2), pre("Line 3")); + van.add(hiddenDom, dom); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); - line2.val = "Line 2: Extra Stuff" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    Line 1
    Line 2: Extra Stuff
    Line 3
    ") + line2.val = "Line 2: Extra Stuff"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2: Extra Stuff
    Line 3
    " + ); // null to remove text DOM - line2.val = null - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    Line 1
    Line 3
    ") + line2.val = null; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 3
    " + ); // Resetting the state won't bring the text DOM back - line2.val = "Line 2" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    Line 1
    Line 3
    ") + line2.val = "Line 2"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 3
    " + ); }); - it('should not update child when state is disconnected', async () => { - const line2 = van.state("Line 2") - const dom = div( - pre("Line 1"), - pre(line2), - pre("Line 3") - ) - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") - - line2.val = "Line 2: Extra Stuff" - await sleep(waitMsForDerivations) + it("should not update child when state is disconnected", async () => { + const line2 = van.state("Line 2"); + const dom = div(pre("Line 1"), pre(line2), pre("Line 3")); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); + + line2.val = "Line 2: Extra Stuff"; + await sleep(waitMsForDerivations); // Content won't change as dom is not connected to document - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); - line2.val = null - await sleep(waitMsForDerivations) + line2.val = null; + await sleep(waitMsForDerivations); // Content won't change as dom is not connected to document - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); }); - it('should not delete dom when child is a state', async () => { - const text = van.state("Text") - const dom = p(text) - van.add(createHiddenDom(), dom) - expect(dom.outerHTML).toBe("

    Text

    ") - text.val = "" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    ") - text.val = "Text" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Text

    ") + it("should not delete dom when child is a state", async () => { + const text = van.state("Text"); + const dom = p(text); + van.add(createHiddenDom(), dom); + expect(dom.outerHTML).toBe("

    Text

    "); + text.val = ""; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    "); + text.val = "Text"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    Text

    "); }); - it('should create svg elements', () => { + it("should create svg elements", () => { const { circle, path, svg } = van.tags("http://www.w3.org/2000/svg"); const dom = svg( { width: "16px", viewBox: "0 0 50 50" }, @@ -503,14 +566,24 @@ describe("van", () => { ); }); - it('should create math elements', () => { - const { math, mi, mn, mo, mrow, msup } = van.tags("http://www.w3.org/1998/Math/MathML") - const dom = math(msup(mi("e"), mrow(mi("i"), mi("π"))), mo("+"), mn("1"), mo("="), mn("0")) - expect(dom.outerHTML).toBe('eiπ+1=0') - }) + it("should create math elements", () => { + const { math, mi, mn, mo, mrow, msup } = van.tags( + "http://www.w3.org/1998/Math/MathML" + ); + const dom = math( + msup(mi("e"), mrow(mi("i"), mi("π"))), + mo("+"), + mn("1"), + mo("="), + mn("0") + ); + expect(dom.outerHTML).toBe( + "eiπ+1=0" + ); + }); }); - describe('add', () => { + describe("add", () => { it("should add elements to the document", () => { const dom = ul(); expect(van.add(dom, li("Item 1"), li("Item 2"))).toBe(dom); @@ -526,30 +599,34 @@ describe("van", () => { ); }); - it('should add nested elements', () => { - const dom = ul() - expect(van.add(dom, [li("Item 1"), li("Item 2")])).toBe(dom) - expect(dom.outerHTML).toBe("
    • Item 1
    • Item 2
    ") + it("should add nested elements", () => { + const dom = ul(); + expect(van.add(dom, [li("Item 1"), li("Item 2")])).toBe(dom); + expect(dom.outerHTML).toBe("
    • Item 1
    • Item 2
    "); }); - it('should add deeply nested elements', () => { + it("should add deeply nested elements", () => { const dom = ul(); van.add(dom, [li("Item 1"), li("Item 2")]); // Deeply nested - expect(van.add(dom, [[li("Item 3"), [li("Item 4")]], li("Item 5")])).toBe(dom) - expect(dom.outerHTML).toBe("
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    ") + expect(van.add(dom, [[li("Item 3"), [li("Item 4")]], li("Item 5")])).toBe( + dom + ); + expect(dom.outerHTML).toBe( + "
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    " + ); }); - it('should ignore empty array', () => { - const dom = ul() + it("should ignore empty array", () => { + const dom = ul(); van.add(dom, [li("Item 1"), li("Item 2")]); // No-op if no children specified - expect(van.add(dom, [[[]]])).toBe(dom) - expect(dom.outerHTML).toBe("
    • Item 1
    • Item 2
    ") + expect(van.add(dom, [[[]]])).toBe(dom); + expect(dom.outerHTML).toBe("
    • Item 1
    • Item 2
    "); }); - it('should ignore null or undefined children', () => { - const dom = ul() + it("should ignore null or undefined children", () => { + const dom = ul(); expect( van.add(dom, li("Item 1"), li("Item 2"), undefined, li("Item 3"), null) ).toBe(dom); @@ -570,8 +647,8 @@ describe("van", () => { ); }); - it('should ignore nested null or undefined children', () => { - const dom = ul() + it("should ignore nested null or undefined children", () => { + const dom = ul(); van.add(dom, li("Item 1"), li("Item 2"), undefined, li("Item 3"), null); van.add(dom, [li("Item 4"), li("Item 5"), undefined, li("Item 6"), null]); expect( @@ -587,618 +664,762 @@ describe("van", () => { ); }); - it('should add children as connected state', async () => { + it("should add children as connected state", async () => { const hiddenDom = createHiddenDom(); - const line2 = van.state("Line 2") - expect(van.add(hiddenDom, - pre("Line 1"), - pre(line2), - pre("Line 3") - )).toBe(hiddenDom) - expect(hiddenDom.outerHTML).toBe('') - - line2.val = "Line 2: Extra Stuff" - await sleep(waitMsForDerivations) - expect(hiddenDom.outerHTML).toBe('') + const line2 = van.state("Line 2"); + expect(van.add(hiddenDom, pre("Line 1"), pre(line2), pre("Line 3"))).toBe( + hiddenDom + ); + expect(hiddenDom.outerHTML).toBe( + '' + ); + + line2.val = "Line 2: Extra Stuff"; + await sleep(waitMsForDerivations); + expect(hiddenDom.outerHTML).toBe( + '' + ); // null to remove text DOM - line2.val = null - await sleep(waitMsForDerivations) - expect(hiddenDom.outerHTML).toBe('') + line2.val = null; + await sleep(waitMsForDerivations); + expect(hiddenDom.outerHTML).toBe( + '' + ); // Resetting the state won't bring the text DOM back - line2.val = "Line 2" - await sleep(waitMsForDerivations) - expect(hiddenDom.outerHTML).toBe('') - }); - - it('should not change children when state is disconnected', async () => { - const line2 = van.state("Line 2") - const dom = div() - expect(van.add(dom, - pre("Line 1"), - pre(line2), - pre("Line 3") - )).toBe(dom) - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") - - line2.val = "Line 2: Extra Stuff" - await sleep(waitMsForDerivations) + line2.val = "Line 2"; + await sleep(waitMsForDerivations); + expect(hiddenDom.outerHTML).toBe( + '' + ); + }); + + it("should not change children when state is disconnected", async () => { + const line2 = van.state("Line 2"); + const dom = div(); + expect(van.add(dom, pre("Line 1"), pre(line2), pre("Line 3"))).toBe(dom); + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); + + line2.val = "Line 2: Extra Stuff"; + await sleep(waitMsForDerivations); // Content won't change as dom is not connected to document - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); - line2.val = null - await sleep(waitMsForDerivations) + line2.val = null; + await sleep(waitMsForDerivations); // Content won't change as dom is not connected to document - expect(dom.outerHTML).toBe("
    Line 1
    Line 2
    Line 3
    ") + expect(dom.outerHTML).toBe( + "
    Line 1
    Line 2
    Line 3
    " + ); }); }); - describe('state', () => { - it('should return the correct oldVal and val', async () => { + describe("state", () => { + it("should return the correct oldVal and val", async () => { const hiddenDom = createHiddenDom(); - const s = van.state("State Version 1") - expect(s.val).toBe("State Version 1") - expect(s.oldVal).toBe("State Version 1") + const s = van.state("State Version 1"); + expect(s.val).toBe("State Version 1"); + expect(s.oldVal).toBe("State Version 1"); // If the state object doesn't have any bindings, we directly update the `oldVal` - s.val = "State Version 2" - expect(s.val).toBe("State Version 2") - expect(s.oldVal).toBe("State Version 2") + s.val = "State Version 2"; + expect(s.val).toBe("State Version 2"); + expect(s.oldVal).toBe("State Version 2"); - van.add(hiddenDom, s) + van.add(hiddenDom, s); // If the state object has some bindings, `oldVal` refers to its old value until DOM update completes - s.val = "State Version 3" - expect(s.val).toBe("State Version 3") - expect(s.oldVal).toBe("State Version 2") - await sleep(waitMsForDerivations) - expect(s.val).toBe("State Version 3") - expect(s.oldVal).toBe("State Version 3") + s.val = "State Version 3"; + expect(s.val).toBe("State Version 3"); + expect(s.oldVal).toBe("State Version 2"); + await sleep(waitMsForDerivations); + expect(s.val).toBe("State Version 3"); + expect(s.oldVal).toBe("State Version 3"); }); - it('should not trigger derived states when rawVal is set', async () => { + it("should not trigger derived states when rawVal is set", async () => { const hiddenDom = createHiddenDom(); - const history: number[] = [] - const a = van.state(3), b = van.state(5) - const s = van.derive(() => a.rawVal! + b.val!) - van.derive(() => history.push(a.rawVal! + b.val!)) + const history: number[] = []; + const a = van.state(3), + b = van.state(5); + const s = van.derive(() => a.rawVal! + b.val!); + van.derive(() => history.push(a.rawVal! + b.val!)); - van.add(hiddenDom, + van.add( + hiddenDom, input({ type: "text", value: () => a.rawVal! + b.val! }), p(() => a.rawVal! + b.val!) - ) + ); - await sleep(waitMsForDerivations) - expect(s.val).toBe(8) - expect(history).toStrictEqual([8]) - expect(hiddenDom.querySelector("input")!.value).toBe("8") - expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8") + await sleep(waitMsForDerivations); + expect(s.val).toBe(8); + expect(history).toStrictEqual([8]); + expect(hiddenDom.querySelector("input")!.value).toBe("8"); + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8"); // Changing the `val` of `a` won't trigger the derived states, side effects, state-derived // properties and state-derived child nodes, as the value of `a` is accessed via `a.rawVal`. - ++a.val! - await sleep(waitMsForDerivations) - expect(s.val).toBe(8) - expect(history).toStrictEqual([8]) - expect(hiddenDom.querySelector("input")!.value).toBe("8") - expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8") + ++a.val!; + await sleep(waitMsForDerivations); + expect(s.val).toBe(8); + expect(history).toStrictEqual([8]); + expect(hiddenDom.querySelector("input")!.value).toBe("8"); + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("8"); // Changing the `val` of `b` will trigger the derived states, side effects, state-derived // properties and state-derived child nodes, as the value of `b` is accessed via `b.rawVal`. - ++b.val! - await sleep(waitMsForDerivations) - expect(s.val).toBe(10) - expect(history).toStrictEqual([8,10]) - expect(hiddenDom.querySelector("input")!.value).toBe("10") - expect(hiddenDom.querySelector("p")!.innerHTML).toBe("10") + ++b.val!; + await sleep(waitMsForDerivations); + expect(s.val).toBe(10); + expect(history).toStrictEqual([8, 10]); + expect(hiddenDom.querySelector("input")!.value).toBe("10"); + expect(hiddenDom.querySelector("p")!.innerHTML).toBe("10"); }); }); - describe('derive', () => { - it('should trigger callback when val changes', async () => { - const history: string[] = [] - const s = van.state("This") - van.derive(() => history.push(s.val!)) - expect(history).toStrictEqual(["This"]) + describe("derive", () => { + it("should trigger callback when val changes", async () => { + const history: string[] = []; + const s = van.state("This"); + van.derive(() => history.push(s.val!)); + expect(history).toStrictEqual(["This"]); - s.val = "is" - await sleep(waitMsForDerivations) - expect(history).toStrictEqual(["This","is"]); + s.val = "is"; + await sleep(waitMsForDerivations); + expect(history).toStrictEqual(["This", "is"]); - s.val = "a" - await sleep(waitMsForDerivations) - expect(history).toStrictEqual(["This","is","a"]); + s.val = "a"; + await sleep(waitMsForDerivations); + expect(history).toStrictEqual(["This", "is", "a"]); - s.val = "test" - await sleep(waitMsForDerivations) - expect(history).toStrictEqual(["This","is","a","test"]) + s.val = "test"; + await sleep(waitMsForDerivations); + expect(history).toStrictEqual(["This", "is", "a", "test"]); - s.val = "test" - await sleep(waitMsForDerivations) - expect(history).toStrictEqual(["This","is","a","test"]) + s.val = "test"; + await sleep(waitMsForDerivations); + expect(history).toStrictEqual(["This", "is", "a", "test"]); - s.val = "test2" + s.val = "test2"; // "Test2" won't be added into `history` as `s` will be set to "test3" immediately - s.val = "test3" - await sleep(waitMsForDerivations) - expect(history).toStrictEqual(["This","is","a","test","test3"]) + s.val = "test3"; + await sleep(waitMsForDerivations); + expect(history).toStrictEqual(["This", "is", "a", "test", "test3"]); }); it("should trigger derived state callback when val changes", async () => { - const numItems = van.state(0) - const items = van.derive(() => [...Array(numItems.val).keys()].map(i => `Item ${i + 1}`)) - const selectedIndex = van.derive(() => (items.val, 0)) - const selectedItem = van.derive(() => items.val![selectedIndex.val!]) - - numItems.val = 3 - await sleep(waitMsForDerivations) - expect(numItems.val).toBe(3) - expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3") - expect(selectedIndex.val).toBe(0) - expect(selectedItem.val).toBe("Item 1") - - selectedIndex.val = 2 - await sleep(waitMsForDerivations) - expect(selectedIndex.val).toBe(2) - expect(selectedItem.val).toBe("Item 3") - - numItems.val = 5 - await sleep(waitMsForDerivations) - expect(numItems.val).toBe(5) - expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3,Item 4,Item 5") - expect(selectedIndex.val).toBe(0) - expect(selectedItem.val).toBe("Item 1") - - selectedIndex.val = 3 - await sleep(waitMsForDerivations) - expect(selectedIndex.val).toBe(3) - expect(selectedItem.val).toBe("Item 4") - }); - - it('should trigger compute conditional derived state', async () => { - const cond = van.state(true) - const a = van.state(1), b = van.state(2), c = van.state(3), d = van.state(4) - let numEffectTriggered = 0 - const sum = van.derive(() => (++numEffectTriggered, cond.val ? a.val! + b.val! : c.val! + d.val!)) - - expect(sum.val).toBe(3) - expect(numEffectTriggered).toBe(1) - - a.val = 11 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(13) - expect(numEffectTriggered).toBe(2) - - b.val = 12 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(23) - expect(numEffectTriggered).toBe(3) + const numItems = van.state(0); + const items = van.derive(() => + [...Array(numItems.val).keys()].map((i) => `Item ${i + 1}`) + ); + const selectedIndex = van.derive(() => (items.val, 0)); + const selectedItem = van.derive(() => items.val![selectedIndex.val!]); + + numItems.val = 3; + await sleep(waitMsForDerivations); + expect(numItems.val).toBe(3); + expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3"); + expect(selectedIndex.val).toBe(0); + expect(selectedItem.val).toBe("Item 1"); + + selectedIndex.val = 2; + await sleep(waitMsForDerivations); + expect(selectedIndex.val).toBe(2); + expect(selectedItem.val).toBe("Item 3"); + + numItems.val = 5; + await sleep(waitMsForDerivations); + expect(numItems.val).toBe(5); + expect(items.val!.join(",")).toBe("Item 1,Item 2,Item 3,Item 4,Item 5"); + expect(selectedIndex.val).toBe(0); + expect(selectedItem.val).toBe("Item 1"); + + selectedIndex.val = 3; + await sleep(waitMsForDerivations); + expect(selectedIndex.val).toBe(3); + expect(selectedItem.val).toBe("Item 4"); + }); + + it("should trigger compute conditional derived state", async () => { + const cond = van.state(true); + const a = van.state(1), + b = van.state(2), + c = van.state(3), + d = van.state(4); + let numEffectTriggered = 0; + const sum = van.derive( + () => ( + ++numEffectTriggered, cond.val ? a.val! + b.val! : c.val! + d.val! + ) + ); + + expect(sum.val).toBe(3); + expect(numEffectTriggered).toBe(1); + + a.val = 11; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(13); + expect(numEffectTriggered).toBe(2); + + b.val = 12; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(23); + expect(numEffectTriggered).toBe(3); // Changing c or d won't triggered the effect as they're not its current dependencies - c.val = 13 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(23) - expect(numEffectTriggered).toBe(3) - - d.val = 14 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(23) - expect(numEffectTriggered).toBe(3) - - cond.val = false - await sleep(waitMsForDerivations) - expect(sum.val).toBe(27) - expect(numEffectTriggered).toBe(4) - - c.val = 23 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(37) - expect(numEffectTriggered).toBe(5) - - d.val = 24 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(47) - expect(numEffectTriggered).toBe(6) + c.val = 13; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(23); + expect(numEffectTriggered).toBe(3); + + d.val = 14; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(23); + expect(numEffectTriggered).toBe(3); + + cond.val = false; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(27); + expect(numEffectTriggered).toBe(4); + + c.val = 23; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(37); + expect(numEffectTriggered).toBe(5); + + d.val = 24; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(47); + expect(numEffectTriggered).toBe(6); // Changing a or b won't triggered the effect as they're not its current dependencies - a.val = 21 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(47) - expect(numEffectTriggered).toBe(6) + a.val = 21; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(47); + expect(numEffectTriggered).toBe(6); - b.val = 22 - await sleep(waitMsForDerivations) - expect(sum.val).toBe(47) - expect(numEffectTriggered).toBe(6) + b.val = 22; + await sleep(waitMsForDerivations); + expect(sum.val).toBe(47); + expect(numEffectTriggered).toBe(6); }); - it('should not change state when derive throws error', async () => { - const s0 = van.state(1) - const s1 = van.derive(() => s0.val! * 2) + it("should not change state when derive throws error", async () => { + const s0 = van.state(1); + const s1 = van.derive(() => s0.val! * 2); const s2 = van.derive(() => { - if (s0.val! > 1) throw new Error() - return s0.val - }) - const s3 = van.derive(() => s0.val! * s0.val!) + if (s0.val! > 1) throw new Error(); + return s0.val; + }); + const s3 = van.derive(() => s0.val! * s0.val!); - expect(s1.val).toBe(2) - expect(s2.val).toBe(1) - expect(s3.val).toBe(1) + expect(s1.val).toBe(2); + expect(s2.val).toBe(1); + expect(s3.val).toBe(1); - s0.val = 3 - await sleep(waitMsForDerivations) + s0.val = 3; + await sleep(waitMsForDerivations); // The derivation function for `s2` throws an error. // We want to validate the `val` of `s2` remains the same because of the error, // but other derived states are updated as usual. - expect(s1.val).toBe(6) - expect(s2.val).toBe(1) - expect(s3.val).toBe(9) + expect(s1.val).toBe(6); + expect(s2.val).toBe(1); + expect(s3.val).toBe(9); }); - it('should update dom when derived state changes', async () => { + it("should update dom when derived state changes", async () => { const hiddenDom = createHiddenDom(); const CheckboxCounter = () => { - const checked = van.state(false), numChecked = van.state(0) + const checked = van.state(false), + numChecked = van.state(0); van.derive(() => { - if (checked.val) ++numChecked.val! - }) + if (checked.val) ++numChecked.val!; + }); return div( - input({ type: "checkbox", checked, onclick: e => checked.val = ((e as Event).target as HTMLInputElement).checked }), - " Checked ", numChecked, " times. ", - button({ onclick: () => numChecked.val = 0 }, "Reset"), - ) - } + input({ + type: "checkbox", + checked, + onclick: (e) => + (checked.val = ((e as Event).target as HTMLInputElement).checked), + }), + " Checked ", + numChecked, + " times. ", + button({ onclick: () => (numChecked.val = 0) }, "Reset") + ); + }; - van.add(hiddenDom, CheckboxCounter()) + van.add(hiddenDom, CheckboxCounter()); - expect(hiddenDom.innerHTML).toBe('
    Checked 0 times.
    ') + expect(hiddenDom.innerHTML).toBe( + '
    Checked 0 times.
    ' + ); - hiddenDom.querySelector("input")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    Checked 1 times.
    ') + hiddenDom.querySelector("input")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    Checked 1 times.
    ' + ); - hiddenDom.querySelector("input")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    Checked 1 times.
    ') + hiddenDom.querySelector("input")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    Checked 1 times.
    ' + ); - hiddenDom.querySelector("input")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    Checked 2 times.
    ') + hiddenDom.querySelector("input")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    Checked 2 times.
    ' + ); - hiddenDom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    Checked 0 times.
    ') - }) + hiddenDom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    Checked 0 times.
    ' + ); + }); - it('should batch derived state updates', async () => { - const a = van.state(3), b = van.state(5) - let numDerivations = 0 + it("should batch derived state updates", async () => { + const a = van.state(3), + b = van.state(5); + let numDerivations = 0; const s = van.derive(() => { - ++numDerivations - return a.val! + b.val! - }) + ++numDerivations; + return a.val! + b.val!; + }); - expect(s.val).toBe(8) - expect(numDerivations).toBe(1) + expect(s.val).toBe(8); + expect(numDerivations).toBe(1); // Both `a` and `b` will change. `s` will only be re-derived once - ++a.val!, ++b.val! - await sleep(waitMsForDerivations) - expect(s.val).toBe(10) - expect(numDerivations).toBe(2) + ++a.val!, ++b.val!; + await sleep(waitMsForDerivations); + expect(s.val).toBe(10); + expect(numDerivations).toBe(2); // `a` will change, and then change back. No derivation will happen - ++a.val!, --a.val! - await sleep(waitMsForDerivations) - expect(s.val).toBe(10) - expect(numDerivations).toBe(2) + ++a.val!, --a.val!; + await sleep(waitMsForDerivations); + expect(s.val).toBe(10); + expect(numDerivations).toBe(2); }); - it('should batch multilayer derived state updates', async () => { + it("should batch multilayer derived state updates", async () => { const hiddenDom = createHiddenDom(); - const a = van.state(1), b = van.derive(() => a.val! * a.val!) - const c = van.derive(() => b.val! * b.val!), d = van.derive(() => c.val! * c.val!) + const a = van.state(1), + b = van.derive(() => a.val! * a.val!); + const c = van.derive(() => b.val! * b.val!), + d = van.derive(() => c.val! * c.val!); - let numSDerived = 0, numSSquaredDerived = 0 + let numSDerived = 0, + numSSquaredDerived = 0; const s = van.derive(() => { - ++numSDerived - return a.val! + b.val! + c.val! + d.val! - }) - - van.add(hiddenDom, "a = ", a, " b = ", b, " c = ", c, " d = ", d, " s = ", s, - " s^2 = ", () => { - ++numSSquaredDerived - return s.val! * s.val! + ++numSDerived; + return a.val! + b.val! + c.val! + d.val!; + }); + + van.add( + hiddenDom, + "a = ", + a, + " b = ", + b, + " c = ", + c, + " d = ", + d, + " s = ", + s, + " s^2 = ", + () => { + ++numSSquaredDerived; + return s.val! * s.val!; } - ) + ); - expect(hiddenDom.innerHTML).toBe("a = 1 b = 1 c = 1 d = 1 s = 4 s^2 = 16") - expect(numSDerived).toBe(1) - expect(numSSquaredDerived).toBe(1) + expect(hiddenDom.innerHTML).toBe( + "a = 1 b = 1 c = 1 d = 1 s = 4 s^2 = 16" + ); + expect(numSDerived).toBe(1); + expect(numSSquaredDerived).toBe(1); - ++a.val! - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe("a = 2 b = 4 c = 16 d = 256 s = 278 s^2 = 77284") + ++a.val!; + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + "a = 2 b = 4 c = 16 d = 256 s = 278 s^2 = 77284" + ); // `s` is derived 4 times, triggered by `a`, `b`, `c`, `d`, respectively. - expect(numSDerived).toBe(5) + expect(numSDerived).toBe(5); // `s^2` (the `s` derived Text node), is only derived once per one DOM update cycle. - expect(numSSquaredDerived).toBe(2) + expect(numSSquaredDerived).toBe(2); }); - it('should stop updating when there is a cycle in the derivation', async () => { - const a = van.state(1);; - const b = van.derive(() => a.val! + 1) - van.derive(() => a.val = b.val! + 1) + it("should stop updating when there is a cycle in the derivation", async () => { + const a = van.state(1); + const b = van.derive(() => a.val! + 1); + van.derive(() => (a.val = b.val! + 1)); // `a` and `b` are circular dependency. But derivations will stop after limited number of // iterations. - ++a.val! - await sleep(waitMsForDerivations) - expect(a.val).toBe(104) - expect(b.val).toBe(103) + ++a.val!; + await sleep(waitMsForDerivations); + expect(a.val).toBe(104); + expect(b.val).toBe(103); }); - it('should dynamically update dom based on derived state', async () => { + it("should dynamically update dom based on derived state", async () => { const hiddenDom = createHiddenDom(); - const verticalPlacement = van.state(false) - const button1Text = van.state("Button 1"), button2Text = van.state("Button 2"), button3Text = van.state("Button 3") - - const domFunc = () => verticalPlacement.val ? div( - div(button(button1Text)), - div(button(button2Text)), - div(button(button3Text)), - ) : div( - button(button1Text), button(button2Text), button(button3Text), - ) - expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom) - - const dom = hiddenDom.firstChild - expect(dom.outerHTML).toBe("
    ") - button2Text.val = "Button 2: Extra" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    ") - - verticalPlacement.val = true - await sleep(waitMsForDerivations) + const verticalPlacement = van.state(false); + const button1Text = van.state("Button 1"), + button2Text = van.state("Button 2"), + button3Text = van.state("Button 3"); + + const domFunc = () => + verticalPlacement.val + ? div( + div(button(button1Text)), + div(button(button2Text)), + div(button(button3Text)) + ) + : div(button(button1Text), button(button2Text), button(button3Text)); + expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom); + + const dom = hiddenDom.firstChild; + expect(dom.outerHTML).toBe( + "
    " + ); + button2Text.val = "Button 2: Extra"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "
    " + ); + + verticalPlacement.val = true; + await sleep(waitMsForDerivations); // dom is disconnected from the document thus it won't be updated - expect(dom.outerHTML).toBe("
    ") - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - button2Text.val = "Button 2: Extra Extra" - await sleep(waitMsForDerivations) + expect(dom.outerHTML).toBe( + "
    " + ); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + button2Text.val = "Button 2: Extra Extra"; + await sleep(waitMsForDerivations); // Since dom is disconnected from document, its inner button won't be reactive to state changes - expect(dom.outerHTML).toBe("
    ") - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") + expect(dom.outerHTML).toBe( + "
    " + ); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); }); - it('should update dom based on conditional derived state', async () => { + it("should update dom based on conditional derived state", async () => { const hiddenDom = createHiddenDom(); - const cond = van.state(true) - const button1 = van.state("Button 1"), button2 = van.state("Button 2") - const button3 = van.state("Button 3"), button4 = van.state("Button 4") - let numFuncCalled = 0 - const domFunc = () => (++numFuncCalled, cond.val ? - div(button(button1.val), button(button2.val)) : - div(button(button3.val), button(button4.val))) - expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom) - - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(1) - - button1.val = "Button 1-1" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(2) - - button2.val = "Button 2-1" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(3) + const cond = van.state(true); + const button1 = van.state("Button 1"), + button2 = van.state("Button 2"); + const button3 = van.state("Button 3"), + button4 = van.state("Button 4"); + let numFuncCalled = 0; + const domFunc = () => ( + ++numFuncCalled, + cond.val + ? div(button(button1.val), button(button2.val)) + : div(button(button3.val), button(button4.val)) + ); + expect(van.add(hiddenDom, domFunc)).toBe(hiddenDom); + + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(1); + + button1.val = "Button 1-1"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(2); + + button2.val = "Button 2-1"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(3); // Changing button3 or button4 won't triggered the effect as they're not its current dependencies - button3.val = "Button 3-1" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(3) - - button4.val = "Button 4-1" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(3) - - cond.val = false - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(4) - - button3.val = "Button 3-2" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(5) - - button4.val = "Button 4-2" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(6) + button3.val = "Button 3-1"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(3); + + button4.val = "Button 4-1"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(3); + + cond.val = false; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(4); + + button3.val = "Button 3-2"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(5); + + button4.val = "Button 4-2"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(6); // Changing button1 or button2 won't triggered the effect as they're not its current dependencies - button1.val = "Button 1-2" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(6) + button1.val = "Button 1-2"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(6); - button1.val = "Button 2-2" - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe("
    ") - expect(numFuncCalled).toBe(6) + button1.val = "Button 2-2"; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + "
    " + ); + expect(numFuncCalled).toBe(6); }); - it('should rearrange dom when state changes', async () => { + it("should rearrange dom when state changes", async () => { const hiddenDom = createHiddenDom(); - const numItems = van.state(0) - const items = van.derive(() => [...Array(numItems.val).keys()].map(i => `Item ${i + 1}`)) - const selectedIndex = van.derive(() => (items.val, 0)) + const numItems = van.state(0); + const items = van.derive(() => + [...Array(numItems.val).keys()].map((i) => `Item ${i + 1}`) + ); + const selectedIndex = van.derive(() => (items.val, 0)); const domFunc = (dom?: Element) => { // If items aren't changed, we don't need to regenerate the entire dom if (dom && items.val === items.oldVal) { const itemDoms = dom.childNodes; - (itemDoms[selectedIndex.oldVal!]).classList.remove("selected"); - (itemDoms[selectedIndex.val!]).classList.add("selected") - return dom + (itemDoms[selectedIndex.oldVal!]).classList.remove( + "selected" + ); + (itemDoms[selectedIndex.val!]).classList.add("selected"); + return dom; } return ul( - items.val!.map((item: string, i: number) => li({ class: i === selectedIndex.val ? "selected" : "" }, item)) - ) - } - van.add(hiddenDom, domFunc) + items.val!.map((item: string, i: number) => + li({ class: i === selectedIndex.val ? "selected" : "" }, item) + ) + ); + }; + van.add(hiddenDom, domFunc); - numItems.val = 3 - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    ') - const rootDom1stIteration = hiddenDom.firstChild + numItems.val = 3; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    ' + ); + const rootDom1stIteration = hiddenDom.firstChild; - selectedIndex.val = 1 - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    ') + selectedIndex.val = 1; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    ' + ); // Items aren't changed, thus we don't need to regenerate the dom - expect(hiddenDom.firstChild!).toBe(rootDom1stIteration) + expect(hiddenDom.firstChild!).toBe(rootDom1stIteration); - numItems.val = 5 - await sleep(waitMsForDerivations) + numItems.val = 5; + await sleep(waitMsForDerivations); // Items are changed, thus the dom for the list is regenerated - expect((hiddenDom.firstChild).outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    ') - expect(hiddenDom.firstChild !== rootDom1stIteration) + expect((hiddenDom.firstChild).outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    ' + ); + expect(hiddenDom.firstChild !== rootDom1stIteration); // rootDom1stIteration is disconnected from the document and remain unchanged - expect(rootDom1stIteration.outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    ') - const rootDom2ndIteration = hiddenDom.firstChild! + expect(rootDom1stIteration.outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    ' + ); + const rootDom2ndIteration = hiddenDom.firstChild!; - selectedIndex.val = 2 - await sleep(waitMsForDerivations) - expect((hiddenDom.firstChild).outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    ') + selectedIndex.val = 2; + await sleep(waitMsForDerivations); + expect((hiddenDom.firstChild).outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    • Item 4
    • Item 5
    ' + ); // Items aren't changed, thus we don't need to regenerate the dom - expect(hiddenDom.firstChild!).toBe(rootDom2ndIteration) + expect(hiddenDom.firstChild!).toBe(rootDom2ndIteration); // rootDom1stIteration won't be updated as it has already been disconnected from the document - expect(rootDom1stIteration.outerHTML).toBe('
    • Item 1
    • Item 2
    • Item 3
    ') + expect(rootDom1stIteration.outerHTML).toBe( + '
    • Item 1
    • Item 2
    • Item 3
    ' + ); }); - it('should remove dom when it returns null', async () => { + it("should remove dom when it returns null", async () => { const hiddenDom = createHiddenDom(); - const line1 = van.state("Line 1"), line2 = van.state("Line 2"), line3 = van.state("Line 3"), line4 = van.state(""), line5 = van.state(null) + const line1 = van.state("Line 1"), + line2 = van.state("Line 2"), + line3 = van.state("Line 3"), + line4 = van.state(""), + line5 = van.state(null); const dom = div( - () => line1.val === "" ? null : p(line1.val), - () => line2.val === "" ? null : p(line2.val), + () => (line1.val === "" ? null : p(line1.val)), + () => (line2.val === "" ? null : p(line2.val)), p(line3), // line4 won't appear in the DOM tree as its initial value is null - () => line4.val === "" ? null : p(line4.val), + () => (line4.val === "" ? null : p(line4.val)), // line5 won't appear in the DOM tree as its initial value is null - p(line5), - ) - van.add(hiddenDom, dom) + p(line5) + ); + van.add(hiddenDom, dom); - expect(dom.outerHTML).toBe("

    Line 1

    Line 2

    Line 3

    ") + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 2

    Line 3

    " + ); // Delete Line 2 - line2.val = "" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    Line 3

    ") + line2.val = ""; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 3

    " + ); // Deleted dom won't be brought back, even the underlying state is changed back - line2.val = "Line 2" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    Line 3

    ") + line2.val = "Line 2"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 3

    " + ); // Delete Line 3 - line3.val = null - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    ") + line3.val = null; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    Line 1

    "); // Deleted dom won't be brought back, even the underlying state is changed back - line3.val = "Line 3" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    ") - }) + line3.val = "Line 3"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    Line 1

    "); + }); - it('should remove dom when it returns undefined', async () => { + it("should remove dom when it returns undefined", async () => { const hiddenDom = createHiddenDom(); - const line1 = van.state("Line 1"), line2 = van.state("Line 2"), line3 = van.state("Line 3"), line4 = van.state(""), line5 = van.state(undefined) + const line1 = van.state("Line 1"), + line2 = van.state("Line 2"), + line3 = van.state("Line 3"), + line4 = van.state(""), + line5 = van.state(undefined); const dom = div( - () => line1.val === "" ? null : p(line1.val), - () => line2.val === "" ? null : p(line2.val), + () => (line1.val === "" ? null : p(line1.val)), + () => (line2.val === "" ? null : p(line2.val)), p(line3), // line4 won't appear in the DOM tree as its initial value is null - () => line4.val === "" ? null : p(line4.val), + () => (line4.val === "" ? null : p(line4.val)), // line5 won't appear in the DOM tree as its initial value is null - p(line5), - ) - van.add(hiddenDom, dom) + p(line5) + ); + van.add(hiddenDom, dom); - expect(dom.outerHTML).toBe("

    Line 1

    Line 2

    Line 3

    ") + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 2

    Line 3

    " + ); // Delete Line 2 - line2.val = "" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    Line 3

    ") + line2.val = ""; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 3

    " + ); // Deleted dom won't be brought back, even the underlying state is changed back - line2.val = "Line 2" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    Line 3

    ") + line2.val = "Line 2"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + "

    Line 1

    Line 3

    " + ); // Delete Line 3 - line3.val = undefined - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    ") + line3.val = undefined; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    Line 1

    "); // Deleted dom won't be brought back, even the underlying state is changed back - line3.val = "Line 3" - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("

    Line 1

    ") + line3.val = "Line 3"; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("

    Line 1

    "); }); - it('should not remove dom when it returns 0', async () => { + it("should not remove dom when it returns 0", async () => { const hiddenDom = createHiddenDom(); - const state1 = van.state(0), state2 = van.state(1) - const dom = div(state1, () => 1 - state1.val!, state2, () => 1 - state2.val!) - van.add(hiddenDom, dom) - - expect(dom.outerHTML).toBe("
    0110
    ") - - state1.val = 1, state2.val = 0 - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    1001
    ") - }) - - it('should update dom when primitive state changes', async () => { + const state1 = van.state(0), + state2 = van.state(1); + const dom = div( + state1, + () => 1 - state1.val!, + state2, + () => 1 - state2.val! + ); + van.add(hiddenDom, dom); + + expect(dom.outerHTML).toBe("
    0110
    "); + + (state1.val = 1), (state2.val = 0); + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("
    1001
    "); + }); + + it("should update dom when primitive state changes", async () => { const hiddenDom = createHiddenDom(); - const a = van.state(1), b = van.state(2), deleted = van.state(false) - const dom = div(() => deleted.val ? null : a.val! + b.val!) - expect(dom.outerHTML).toBe("
    3
    ") - van.add(hiddenDom, dom) + const a = van.state(1), + b = van.state(2), + deleted = van.state(false); + const dom = div(() => (deleted.val ? null : a.val! + b.val!)); + expect(dom.outerHTML).toBe("
    3
    "); + van.add(hiddenDom, dom); - a.val = 6 - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    8
    ") + a.val = 6; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("
    8
    "); - b.val = 5 - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    11
    ") + b.val = 5; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("
    11
    "); - deleted.val = true - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    ") + deleted.val = true; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("
    "); // Deleted dom won't be brought back, even the underlying state is changed back - deleted.val = false - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe("
    ") - }) + deleted.val = false; + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe("
    "); + }); - it('should not update when state is not connected', async () => { + it("should not update when state is not connected", async () => { const hiddenDom = createHiddenDom(); - const part1 = "👋Hello ", part2 = van.state("🗺️World") + const part1 = "👋Hello ", + part2 = van.state("🗺️World"); expect( van.add( @@ -1207,135 +1428,154 @@ describe("van", () => { ) ).toBe(hiddenDom); - const dom = hiddenDom.firstChild - expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") - expect(hiddenDom.innerHTML).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") + const dom = hiddenDom.firstChild; + expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World"); + expect(hiddenDom.innerHTML).toBe( + "👋Hello 🗺️World, from: 👋Hello 🗺️World" + ); - part2.val = "🍦VanJS" - await sleep(waitMsForDerivations) + part2.val = "🍦VanJS"; + await sleep(waitMsForDerivations); // dom is disconnected from the document thus it won't be updated - expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World") - expect(hiddenDom.innerHTML).toBe("👋Hello 🍦VanJS, from: 👋Hello 🗺️World") + expect(dom.textContent!).toBe("👋Hello 🗺️World, from: 👋Hello 🗺️World"); + expect(hiddenDom.innerHTML).toBe( + "👋Hello 🍦VanJS, from: 👋Hello 🗺️World" + ); }); - it('should not update dom when oldVal is referenced', async () => { + it("should not update dom when oldVal is referenced", async () => { const hiddenDom = createHiddenDom(); - const text = van.state("Old Text") + const text = van.state("Old Text"); - expect(van.add(hiddenDom, () => `From: "${text.oldVal}" to: "${text.val}"`)).toBe(hiddenDom) + expect( + van.add(hiddenDom, () => `From: "${text.oldVal}" to: "${text.val}"`) + ).toBe(hiddenDom); - const dom = hiddenDom.firstChild - expect(dom.textContent!).toBe('From: "Old Text" to: "Old Text"') - expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "Old Text"') + const dom = hiddenDom.firstChild; + expect(dom.textContent!).toBe('From: "Old Text" to: "Old Text"'); + expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "Old Text"'); - text.val = "New Text" - await sleep(waitMsForDerivations) + text.val = "New Text"; + await sleep(waitMsForDerivations); // dom is disconnected from the document thus it won't be updated - expect(dom.textContent).toBe('From: "Old Text" to: "Old Text"') - expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "New Text"') + expect(dom.textContent).toBe('From: "Old Text" to: "Old Text"'); + expect(hiddenDom.innerHTML).toBe('From: "Old Text" to: "New Text"'); }); - it('should not update when state derived children throws error', async () => { + it("should not update when state derived children throws error", async () => { const hiddenDom = createHiddenDom(); - const num = van.state(0) + const num = van.state(0); - expect(van.add(hiddenDom, - num, - () => { - if (num.val! > 0) throw new Error() - return span("ok") - }, - num - )).toBe(hiddenDom) + expect( + van.add( + hiddenDom, + num, + () => { + if (num.val! > 0) throw new Error(); + return span("ok"); + }, + num + ) + ).toBe(hiddenDom); - expect(hiddenDom.innerHTML).toBe("0ok0") + expect(hiddenDom.innerHTML).toBe("0ok0"); - num.val = 1 - await sleep(waitMsForDerivations) + num.val = 1; + await sleep(waitMsForDerivations); // The binding function 2nd child of hiddenDom throws an error. // We want to validate the 2nd child won't be updated because of the error, // but other DOM nodes are updated as usual - expect(hiddenDom.innerHTML).toBe("1ok1") + expect(hiddenDom.innerHTML).toBe("1ok1"); }); }); - describe('hydrate', () => { - it('should hydrate the given dom with the provided state', async () => { + describe("hydrate", () => { + it("should hydrate the given dom with the provided state", async () => { const hiddenDom = createHiddenDom(); const Counter = (init: number) => { - const counter = van.state(init) - return button({ "data-counter": counter, onclick: () => ++counter.val! }, - () => `Count: ${counter.val}`, - ) - } + const counter = van.state(init); + return button( + { "data-counter": counter, onclick: () => ++counter.val! }, + () => `Count: ${counter.val}` + ); + }; // Static DOM before hydration - hiddenDom.innerHTML = Counter(5).outerHTML + hiddenDom.innerHTML = Counter(5).outerHTML; // Before hydration, the counter is not reactive - hiddenDom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('') + hiddenDom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '' + ); - van.hydrate(hiddenDom.querySelector("button")!, - (dom: HTMLElement) => Counter(Number(dom.getAttribute("data-counter")))) + van.hydrate(hiddenDom.querySelector("button")!, (dom: HTMLElement) => + Counter(Number(dom.getAttribute("data-counter"))) + ); // After hydration, the counter is reactive - hiddenDom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('') - }); + hiddenDom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '' + ); + }); - it('should remove dom when it returns null', async () => { + it("should remove dom when it returns null", async () => { const hiddenDom = createHiddenDom(); // Remove the DOM node upon hydration - van.add(hiddenDom, div()) - van.hydrate(hiddenDom.querySelector("div")!, () => null) - expect(hiddenDom.innerHTML).toBe("") + van.add(hiddenDom, div()); + van.hydrate(hiddenDom.querySelector("div")!, () => null); + expect(hiddenDom.innerHTML).toBe(""); // Remove the DOM node after the state update - van.add(hiddenDom, div()) - const s = van.state(1) - van.hydrate(hiddenDom.querySelector("div"), () => s.val === 1 ? pre() : null) - expect(hiddenDom.innerHTML).toBe("
    ")
    -      s.val = 2
    -      await sleep(waitMsForDerivations)
    -      expect(hiddenDom.innerHTML).toBe("")
    -  })
    -
    -  it('should remove dom when it returns undefined', async () => {
    -  const hiddenDom = createHiddenDom();
    +      van.add(hiddenDom, div());
    +      const s = van.state(1);
    +      van.hydrate(hiddenDom.querySelector("div"), () =>
    +        s.val === 1 ? pre() : null
    +      );
    +      expect(hiddenDom.innerHTML).toBe("
    ");
    +      s.val = 2;
    +      await sleep(waitMsForDerivations);
    +      expect(hiddenDom.innerHTML).toBe("");
    +    });
    +
    +    it("should remove dom when it returns undefined", async () => {
    +      const hiddenDom = createHiddenDom();
           // Remove the DOM node upon hydration
    -      van.add(hiddenDom, div())
    -      van.hydrate(hiddenDom.querySelector("div")!, () => undefined)
    -      expect(hiddenDom.innerHTML).toBe("")
    +      van.add(hiddenDom, div());
    +      van.hydrate(hiddenDom.querySelector("div")!, () => undefined);
    +      expect(hiddenDom.innerHTML).toBe("");
     
           // Remove the DOM node after the state update
    -      van.add(hiddenDom, div())
    -      const s = van.state(1)
    -      van.hydrate(hiddenDom.querySelector("div"), () => s.val === 1 ? pre() : undefined)
    -      expect(hiddenDom.innerHTML).toBe("
    ")
    -      s.val = 2
    -      await sleep(waitMsForDerivations)
    -      expect(hiddenDom.innerHTML).toBe("")
    -  });
    +      van.add(hiddenDom, div());
    +      const s = van.state(1);
    +      van.hydrate(hiddenDom.querySelector("div"), () =>
    +        s.val === 1 ? pre() : undefined
    +      );
    +      expect(hiddenDom.innerHTML).toBe("
    ");
    +      s.val = 2;
    +      await sleep(waitMsForDerivations);
    +      expect(hiddenDom.innerHTML).toBe("");
    +    });
     
    -  it('should not remove dom when it returns 0', async () => {
    -  const hiddenDom = createHiddenDom();
    -      van.add(hiddenDom, div(), div())
    +    it("should not remove dom when it returns 0", async () => {
    +      const hiddenDom = createHiddenDom();
    +      van.add(hiddenDom, div(), div());
     
    -      const s = van.state(0)
    -      const [dom1, dom2] = hiddenDom.querySelectorAll("div")
    +      const s = van.state(0);
    +      const [dom1, dom2] = hiddenDom.querySelectorAll("div");
     
    -      van.hydrate(dom1, (() => s.val))
    -      van.hydrate(dom2, (() => 1 - s.val!))
    -      expect(hiddenDom.innerHTML).toBe("01")
    +      van.hydrate(dom1, (() => s.val));
    +      van.hydrate(dom2, (() => 1 - s.val!));
    +      expect(hiddenDom.innerHTML).toBe("01");
     
    -      s.val = 1
    -      await sleep(waitMsForDerivations)
    -      expect(hiddenDom.innerHTML).toBe("10")
    -  });
    +      s.val = 1;
    +      await sleep(waitMsForDerivations);
    +      expect(hiddenDom.innerHTML).toBe("10");
    +    });
       });
     
       describe("gc", () => {
    @@ -1558,341 +1798,464 @@ describe("van", () => {
       });
     
       describe("e2e", () => {
    -    it('should render Counter and update dom accordingly', async () => {
    +    it("should render Counter and update dom accordingly", async () => {
           const hiddenDom = createHiddenDom();
           const Counter = () => {
    -        const counter = van.state(0)
    +        const counter = van.state(0);
             return div(
               div("❤️: ", counter),
               button({ onclick: () => ++counter.val! }, "👍"),
    -          button({ onclick: () => --counter.val! }, "👎"),
    -        )
    -      }
    +          button({ onclick: () => --counter.val! }, "👎")
    +        );
    +      };
     
    -      van.add(hiddenDom, Counter())
    +      van.add(hiddenDom, Counter());
     
    -      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 0")
    +      expect(
    +        (hiddenDom.firstChild).querySelector("div")!.innerHTML
    +      ).toBe("❤️: 0");
     
    -      const [incrementBtn, decrementBtn] = hiddenDom.getElementsByTagName("button")
    +      const [incrementBtn, decrementBtn] =
    +        hiddenDom.getElementsByTagName("button");
     
    -      incrementBtn.click()
    -      await sleep(waitMsForDerivations)
    -      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 1")
    +      incrementBtn.click();
    +      await sleep(waitMsForDerivations);
    +      expect(
    +        (hiddenDom.firstChild).querySelector("div")!.innerHTML
    +      ).toBe("❤️: 1");
     
    -      incrementBtn.click()
    -      await sleep(waitMsForDerivations)
    -      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 2")
    +      incrementBtn.click();
    +      await sleep(waitMsForDerivations);
    +      expect(
    +        (hiddenDom.firstChild).querySelector("div")!.innerHTML
    +      ).toBe("❤️: 2");
     
    -      decrementBtn.click()
    -      await sleep(waitMsForDerivations)
    -      expect((hiddenDom.firstChild).querySelector("div")!.innerHTML).toBe("❤️: 1")
    +      decrementBtn.click();
    +      await sleep(waitMsForDerivations);
    +      expect(
    +        (hiddenDom.firstChild).querySelector("div")!.innerHTML
    +      ).toBe("❤️: 1");
         });
     
    -    it('should render ul li', () => {
    +    it("should render ul li", () => {
           const List = ({ items }: { items: string[] }) =>
             ul(items.map((it: any) => li(it)));
           expect(List({ items: ["Item 1", "Item 2", "Item 3"] }).outerHTML).toBe(
             "
    • Item 1
    • Item 2
    • Item 3
    " ); - }) + }); - it('should render table', () => { - const Table = ({ head, data }: - { head?: readonly string[], data: readonly (string | number)[][] }) => table( - head ? thead(tr(head.map(h => th(h)))) : [], - tbody(data.map(row => tr( - row.map(col => td(col)) - ))), - ) + it("should render table", () => { + const Table = ({ + head, + data, + }: { + head?: readonly string[]; + data: readonly (string | number)[][]; + }) => + table( + head ? thead(tr(head.map((h) => th(h)))) : [], + tbody(data.map((row) => tr(row.map((col) => td(col))))) + ); - expect(Table({ - head: ["ID", "Name", "Country"], - data: [ - [1, "John Doe", "US"], - [2, "Jane Smith", "CA"], - [3, "Bob Johnson", "AU"], - ], - }).outerHTML).toBe("
    IDNameCountry
    1John DoeUS
    2Jane SmithCA
    3Bob JohnsonAU
    ") - - expect(Table({ - data: [ - [1, "John Doe", "US"], - [2, "Jane Smith", "CA"], - ], - }).outerHTML).toBe("
    1John DoeUS
    2Jane SmithCA
    ") - }) - - it('should render and update dom after changing state', async () => { + expect( + Table({ + head: ["ID", "Name", "Country"], + data: [ + [1, "John Doe", "US"], + [2, "Jane Smith", "CA"], + [3, "Bob Johnson", "AU"], + ], + }).outerHTML + ).toBe( + "
    IDNameCountry
    1John DoeUS
    2Jane SmithCA
    3Bob JohnsonAU
    " + ); + + expect( + Table({ + data: [ + [1, "John Doe", "US"], + [2, "Jane Smith", "CA"], + ], + }).outerHTML + ).toBe( + "
    1John DoeUS
    2Jane SmithCA
    " + ); + }); + + it("should render and update dom after changing state", async () => { const hiddenDom = createHiddenDom(); // Create a new state object with init value 1 - const counter = van.state(1) + const counter = van.state(1); // Log whenever the value of the state is updated - van.derive(() => console.log(`Counter: ${counter.val}`)) + van.derive(() => console.log(`Counter: ${counter.val}`)); // Derived state - const counterSquared = van.derive(() => counter.val! * counter.val!) + const counterSquared = van.derive(() => counter.val! * counter.val!); // Used as a child node - const dom1 = div(counter) + const dom1 = div(counter); // Used as a property - const dom2 = input({ type: "number", value: counter, disabled: true }) + const dom2 = input({ type: "number", value: counter, disabled: true }); // Used in a state-derived property - const dom3 = div({ style: () => `font-size: ${counter.val}em;` }, "Text") + const dom3 = div({ style: () => `font-size: ${counter.val}em;` }, "Text"); // Used in a state-derived child - const dom4 = div(counter, sup(2), () => ` = ${counterSquared.val}`) + const dom4 = div(counter, sup(2), () => ` = ${counterSquared.val}`); // Button to increment the value of the state - const incrementBtn = button({ onclick: () => ++counter.val! }, "Increment") - const resetBtn = button({ onclick: () => counter.val = 1 }, "Reset") + const incrementBtn = button( + { onclick: () => ++counter.val! }, + "Increment" + ); + const resetBtn = button({ onclick: () => (counter.val = 1) }, "Reset"); - van.add(hiddenDom, incrementBtn, resetBtn, dom1, dom2, dom3, dom4) + van.add(hiddenDom, incrementBtn, resetBtn, dom1, dom2, dom3, dom4); - expect(hiddenDom.innerHTML).toBe('
    1
    Text
    12 = 1
    ') - expect(dom2.value).toBe("1") + expect(hiddenDom.innerHTML).toBe( + '
    1
    Text
    12 = 1
    ' + ); + expect(dom2.value).toBe("1"); - incrementBtn.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    2
    Text
    22 = 4
    ') - expect(dom2.value).toBe("2") + incrementBtn.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    2
    Text
    22 = 4
    ' + ); + expect(dom2.value).toBe("2"); - incrementBtn.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    3
    Text
    32 = 9
    ') - expect(dom2.value).toBe("3") + incrementBtn.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    3
    Text
    32 = 9
    ' + ); + expect(dom2.value).toBe("3"); - resetBtn.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('
    1
    Text
    12 = 1
    ') - expect(dom2.value).toBe("1") - }) + resetBtn.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '
    1
    Text
    12 = 1
    ' + ); + expect(dom2.value).toBe("1"); + }); - it('should update dom based on derived state', async () => { + it("should update dom based on derived state", async () => { const hiddenDom = createHiddenDom(); const DerivedState = () => { - const text = van.state("VanJS") - const length = van.derive(() => text.val!.length) + const text = van.state("VanJS"); + const length = van.derive(() => text.val!.length); return span( "The length of ", - input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), - " is ", length, ".", - ) - } + input({ + type: "text", + value: text, + oninput: (e: any) => (text.val = e.target.value), + }), + " is ", + length, + "." + ); + }; - van.add(hiddenDom, DerivedState()) - const dom = (hiddenDom.firstChild) - expect(dom.outerHTML).toBe('The length of is 5.') + van.add(hiddenDom, DerivedState()); + const dom = hiddenDom.firstChild; + expect(dom.outerHTML).toBe( + 'The length of is 5.' + ); - const inputDom = dom.querySelector("input")! - inputDom.value = "Mini-Van" - inputDom.dispatchEvent(new Event("input")) + const inputDom = dom.querySelector("input")!; + inputDom.value = "Mini-Van"; + inputDom.dispatchEvent(new Event("input")); - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe('The length of is 8.') - }) + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + 'The length of is 8.' + ); + }); - it('should update props based on state', async () => { + it("should update props based on state", async () => { const hiddenDom = createHiddenDom(); const ConnectedProps = () => { - const text = van.state("") + const text = van.state(""); return span( - input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), - input({ type: "text", value: text, oninput: (e: any) => text.val = e.target.value }), - ) - } - van.add(hiddenDom, ConnectedProps()) - - const [input1, input2] = hiddenDom.querySelectorAll("input") - input1.value += "123" - input1.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - expect(input1.value).toBe("123") - expect(input2.value).toBe("123") - - input2.value += "abc" - input2.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - expect(input1.value).toBe("123abc") - expect(input2.value).toBe("123abc") - }) - - it('should update css based on state', async () => { + input({ + type: "text", + value: text, + oninput: (e: any) => (text.val = e.target.value), + }), + input({ + type: "text", + value: text, + oninput: (e: any) => (text.val = e.target.value), + }) + ); + }; + van.add(hiddenDom, ConnectedProps()); + + const [input1, input2] = hiddenDom.querySelectorAll("input"); + input1.value += "123"; + input1.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + expect(input1.value).toBe("123"); + expect(input2.value).toBe("123"); + + input2.value += "abc"; + input2.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + expect(input1.value).toBe("123abc"); + expect(input2.value).toBe("123abc"); + }); + + it("should update css based on state", async () => { const hiddenDom = createHiddenDom(); const FontPreview = () => { - const size = van.state(16), color = van.state("black") + const size = van.state(16), + color = van.state("black"); return span( "Size: ", input({ - type: "range", min: 10, max: 36, value: size, - oninput: (e: any) => size.val = Number((e.target).value) + type: "range", + min: 10, + max: 36, + value: size, + oninput: (e: any) => + (size.val = Number((e.target).value)), }), " Color: ", - select({ oninput: (e: any) => color.val = (e.target).value, value: color }, - ["black", "blue", "green", "red", "brown"].map(c => option({ value: c }, c)), + select( + { + oninput: (e: any) => + (color.val = (e.target).value), + value: color, + }, + ["black", "blue", "green", "red", "brown"].map((c) => + option({ value: c }, c) + ) ), span( { class: "preview", style: () => `font-size: ${size.val}px; color: ${color.val};`, - }, " Hello 🍦VanJS"), - ) - } - van.add(hiddenDom, FontPreview()) + }, + " Hello 🍦VanJS" + ) + ); + }; + van.add(hiddenDom, FontPreview()); expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( "font-size: 16px; color: black;" ); - hiddenDom.querySelector("input")!.value = "20" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "20"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( "font-size: 20px; color: black;" ); - hiddenDom.querySelector("select")!.value = "blue" - hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) + hiddenDom.querySelector("select")!.value = "blue"; + hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); expect((hiddenDom.querySelector("span.preview")).style.cssText).toBe( "font-size: 20px; color: blue;" ); }); - it('should bind event listener based on derived state', async () => { + it("should bind event listener based on derived state", async () => { const hiddenDom = createHiddenDom(); const Counter = () => { - const counter = van.state(0) - const action = van.state("👍") + const counter = van.state(0); + const action = van.state("👍"); return span( - "❤️ ", counter, " ", - select({ oninput: (e: any) => action.val = e.target.value, value: action }, - option({ value: "👍" }, "👍"), option({ value: "👎" }, "👎"), - ), " ", - button({ - onclick: van.derive(() => action.val === "👍" ? - () => ++counter.val! : () => --counter.val!) - }, "Run"), - ) - } + "❤️ ", + counter, + " ", + select( + { + oninput: (e: any) => (action.val = e.target.value), + value: action, + }, + option({ value: "👍" }, "👍"), + option({ value: "👎" }, "👎") + ), + " ", + button( + { + onclick: van.derive(() => + action.val === "👍" + ? () => ++counter.val! + : () => --counter.val! + ), + }, + "Run" + ) + ); + }; - van.add(hiddenDom, Counter()) - const dom = (hiddenDom.firstChild) - expect(dom.outerHTML).toBe('❤️ 0 ') + van.add(hiddenDom, Counter()); + const dom = hiddenDom.firstChild; + expect(dom.outerHTML).toBe( + '❤️ 0 ' + ); - dom.querySelector("button")!.click() - dom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe('❤️ 2 ') + dom.querySelector("button")!.click(); + dom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + '❤️ 2 ' + ); - dom.querySelector("select")!.value = "👎" - dom.querySelector("select")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - dom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe('❤️ 1 ') + dom.querySelector("select")!.value = "👎"; + dom.querySelector("select")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + dom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + '❤️ 1 ' + ); }); - it('should render nested ul li', async () => { + it("should render nested ul li", async () => { const hiddenDom = createHiddenDom(); const SortedList = () => { - const items = van.state("a,b,c"), sortedBy = van.state("Ascending") + const items = van.state("a,b,c"), + sortedBy = van.state("Ascending"); return span( "Comma-separated list: ", input({ - oninput: (e: any) => items.val = (e.target).value, - type: "text", value: items - }), " ", - select({ oninput: (e: any) => sortedBy.val = (e.target).value, value: sortedBy }, + oninput: (e: any) => + (items.val = (e.target).value), + type: "text", + value: items, + }), + " ", + select( + { + oninput: (e: any) => + (sortedBy.val = (e.target).value), + value: sortedBy, + }, option({ value: "Ascending" }, "Ascending"), - option({ value: "Descending" }, "Descending"), + option({ value: "Descending" }, "Descending") ), // A State-derived child node - () => sortedBy.val === "Ascending" ? - ul(items.val!.split(",").sort().map(i => li(i))) : - ul(items.val!.split(",").sort().reverse().map(i => li(i))), - ) - } - van.add(hiddenDom, SortedList()) + () => + sortedBy.val === "Ascending" + ? ul( + items + .val!.split(",") + .sort() + .map((i) => li(i)) + ) + : ul( + items + .val!.split(",") + .sort() + .reverse() + .map((i) => li(i)) + ) + ); + }; + van.add(hiddenDom, SortedList()); - hiddenDom.querySelector("input")!.value = "a,b,c,d" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) + hiddenDom.querySelector("input")!.value = "a,b,c,d"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( - "
    • a
    • b
    • c
    • d
    ") + "
    • a
    • b
    • c
    • d
    " + ); - hiddenDom.querySelector("select")!.value = "Descending" - hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) + hiddenDom.querySelector("select")!.value = "Descending"; + hiddenDom.querySelector("select")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( - "
    • d
    • c
    • b
    • a
    ") - }) + "
    • d
    • c
    • b
    • a
    " + ); + }); - it('should render editable ul li', async() => { + it("should render editable ul li", async () => { const hiddenDom = createHiddenDom(); - const ListItem = ({ text }: {text: string}) => { - const deleted = van.state(false) - return () => deleted.val ? null : li( - text, - a({ onclick: () => deleted.val = true }, "❌"), - ) - } + const ListItem = ({ text }: { text: string }) => { + const deleted = van.state(false); + return () => + deleted.val + ? null + : li(text, a({ onclick: () => (deleted.val = true) }, "❌")); + }; const EditableList = () => { - const listDom = ul() - const textDom = input({ type: "text" }) + const listDom = ul(); + const textDom = input({ type: "text" }); return div( - textDom, " ", - button({ onclick: () => van.add(listDom, ListItem({ text: textDom.value })) }, "➕"), - listDom, - ) - } - van.add(hiddenDom, EditableList()) - - hiddenDom.querySelector("input")!.value = "abc" - hiddenDom.querySelector("button")!.click() - hiddenDom.querySelector("input")!.value = "123" - hiddenDom.querySelector("button")!.click() - hiddenDom.querySelector("input")!.value = "def" - hiddenDom.querySelector("button")!.click() - await sleep(waitMsForDerivations) + textDom, + " ", + button( + { + onclick: () => + van.add(listDom, ListItem({ text: textDom.value })), + }, + "➕" + ), + listDom + ); + }; + van.add(hiddenDom, EditableList()); + + hiddenDom.querySelector("input")!.value = "abc"; + hiddenDom.querySelector("button")!.click(); + hiddenDom.querySelector("input")!.value = "123"; + hiddenDom.querySelector("button")!.click(); + hiddenDom.querySelector("input")!.value = "def"; + hiddenDom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( - "") + "" + ); { - [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("123"))! - .querySelector("a")!.click() - await sleep(waitMsForDerivations) + [...hiddenDom.querySelectorAll("li")] + .find((e) => e.innerHTML.startsWith("123"))! + .querySelector("a")! + .click(); + await sleep(waitMsForDerivations); expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( - "") + "" + ); } { - [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("abc"))! - .querySelector("a")!.click() - await sleep(waitMsForDerivations) + [...hiddenDom.querySelectorAll("li")] + .find((e) => e.innerHTML.startsWith("abc"))! + .querySelector("a")! + .click(); + await sleep(waitMsForDerivations); expect(hiddenDom.querySelector("ul")!.outerHTML).toBe( - "") + "" + ); } { - [...hiddenDom.querySelectorAll("li")].find(e => e.innerHTML.startsWith("def"))! - .querySelector("a")!.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.querySelector("ul")!.outerHTML).toBe("
      ") + [...hiddenDom.querySelectorAll("li")] + .find((e) => e.innerHTML.startsWith("def"))! + .querySelector("a")! + .click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.querySelector("ul")!.outerHTML).toBe("
        "); } - }) + }); - it('should update dom based on polymorphic state', async () => { - const stateProto = Object.getPrototypeOf(van.state()) + it("should update dom based on polymorphic state", async () => { + const stateProto = Object.getPrototypeOf(van.state()); const hiddenDom = createHiddenDom(); - let numYellowButtonClicked = 0 + let numYellowButtonClicked = 0; const val = (v: T | State | (() => T)) => { - const protoOfV = Object.getPrototypeOf(v ?? 0) - if (protoOfV === stateProto) return (>v).val - if (protoOfV === Function.prototype) return (<() => T>v)() - return v - } + const protoOfV = Object.getPrototypeOf(v ?? 0); + if (protoOfV === stateProto) return (>v).val; + if (protoOfV === Function.prototype) return (<() => T>v)(); + return v; + }; const Button = ({ color, @@ -1909,150 +2272,183 @@ describe("van", () => { ); const App = () => { - const colorState = van.state("green") - const textState = van.state("Turn Red") + const colorState = van.state("green"); + const textState = van.state("Turn Red"); const turnRed = () => { - colorState.val = "red" - textState.val = "Turn Green" - onclickState.val = turnGreen - } + colorState.val = "red"; + textState.val = "Turn Green"; + onclickState.val = turnGreen; + }; const turnGreen = () => { - colorState.val = "green" - textState.val = "Turn Red" - onclickState.val = turnRed - } - const onclickState = van.state(turnRed) + colorState.val = "green"; + textState.val = "Turn Red"; + onclickState.val = turnRed; + }; + const onclickState = van.state(turnRed); - const lightness = van.state(255) + const lightness = van.state(255); return span( - Button({ color: "yellow", text: "Click Me", onclick: () => ++numYellowButtonClicked }), " ", - Button({ color: colorState, text: textState, onclick: onclickState }), " ", Button({ - color: () => `rgb(${lightness.val}, ${lightness.val}, ${lightness.val})`, - text: "Get Darker", - onclick: () => lightness.val = Math.max(lightness.val! - 10, 0), + color: "yellow", + text: "Click Me", + onclick: () => ++numYellowButtonClicked, }), - ) - } + " ", + Button({ color: colorState, text: textState, onclick: onclickState }), + " ", + Button({ + color: () => + `rgb(${lightness.val}, ${lightness.val}, ${lightness.val})`, + text: "Get Darker", + onclick: () => (lightness.val = Math.max(lightness.val! - 10, 0)), + }) + ); + }; - van.add(hiddenDom, App()) + van.add(hiddenDom, App()); - expect(hiddenDom.innerHTML).toBe(' ') - const [button1, button2, button3] = hiddenDom.querySelectorAll("button") + expect(hiddenDom.innerHTML).toBe( + ' ' + ); + const [button1, button2, button3] = hiddenDom.querySelectorAll("button"); - button1.click() - expect(numYellowButtonClicked).toBe(1) - button1.click() - expect(numYellowButtonClicked).toBe(2) + button1.click(); + expect(numYellowButtonClicked).toBe(1); + button1.click(); + expect(numYellowButtonClicked).toBe(2); - button2.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe(' ') - button2.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe(' ') + button2.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + ' ' + ); + button2.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + ' ' + ); - button3.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe(' ') - button3.click() - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe(' ') + button3.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + ' ' + ); + button3.click(); + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + ' ' + ); }); - it('should update dom based on state', async () => { + it("should update dom based on state", async () => { const hiddenDom = createHiddenDom(); const TurnBold = () => { - const vanJS = van.state("VanJS") + const vanJS = van.state("VanJS"); return span( - button({ onclick: () => vanJS.val = b("VanJS") }, "Turn Bold"), - " Welcome to ", vanJS, ". ", vanJS, " is awesome!" - ) - } + button({ onclick: () => (vanJS.val = b("VanJS")) }, "Turn Bold"), + " Welcome to ", + vanJS, + ". ", + vanJS, + " is awesome!" + ); + }; - van.add(hiddenDom, TurnBold()) - const dom = (hiddenDom.firstChild) - expect(dom.outerHTML).toBe(" Welcome to VanJS. VanJS is awesome!") + van.add(hiddenDom, TurnBold()); + const dom = hiddenDom.firstChild; + expect(dom.outerHTML).toBe( + " Welcome to VanJS. VanJS is awesome!" + ); - dom.querySelector("button")!.click() - await sleep(waitMsForDerivations) - expect(dom.outerHTML).toBe(" Welcome to . VanJS is awesome!") - }) + dom.querySelector("button")!.click(); + await sleep(waitMsForDerivations); + expect(dom.outerHTML).toBe( + " Welcome to . VanJS is awesome!" + ); + }); - it('should batch updates', async () => { + it("should batch updates", async () => { const hiddenDom = createHiddenDom(); - const name = van.state("") + const name = van.state(""); const Name1 = () => { - const numRendered = van.state(0) - return div( - () => { - ++numRendered.val! - return name.val!.trim().length === 0 ? - p("Please enter your name") : - p("Hello ", b(name)) - }, - p(i("The

        element has been rendered ", numRendered, " time(s).")), - ) - } + const numRendered = van.state(0); + return div(() => { + ++numRendered.val!; + return name.val!.trim().length === 0 + ? p("Please enter your name") + : p("Hello ", b(name)); + }, p(i("The

        element has been rendered ", numRendered, " time(s)."))); + }; const Name2 = () => { - const numRendered = van.state(0) - const isNameEmpty = van.derive(() => name.val!.trim().length === 0) - return div( - () => { - ++numRendered.val! - return isNameEmpty.val ? - p("Please enter your name") : - p("Hello ", b(name)) - }, - p(i("The

        element has been rendered ", numRendered, " time(s).")), - ) - } + const numRendered = van.state(0); + const isNameEmpty = van.derive(() => name.val!.trim().length === 0); + return div(() => { + ++numRendered.val!; + return isNameEmpty.val + ? p("Please enter your name") + : p("Hello ", b(name)); + }, p(i("The

        element has been rendered ", numRendered, " time(s)."))); + }; - van.add(hiddenDom, - p("Your name is: ", input({ type: "text", value: name, oninput: (e: any) => name.val = e.target.value })), + van.add( + hiddenDom, + p( + "Your name is: ", + input({ + type: "text", + value: name, + oninput: (e: any) => (name.val = e.target.value), + }) + ), Name1(), - Name2(), - ) - await sleep(waitMsForDerivations) + Name2() + ); + await sleep(waitMsForDerivations); expect(hiddenDom.innerHTML).toBe( '

        Your name is:

        Please enter your name

        The <p> element has been rendered 1 time(s).

        Please enter your name

        The <p> element has been rendered 1 time(s).

        ' ); - hiddenDom.querySelector("input")!.value = "T" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - hiddenDom.querySelector("input")!.value = "Ta" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - hiddenDom.querySelector("input")!.value = "Tao" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('

        Your name is:

        Hello Tao

        The <p> element has been rendered 4 time(s).

        Hello Tao

        The <p> element has been rendered 2 time(s).

        ') - - hiddenDom.querySelector("input")!.value = "" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations * 2) - expect(hiddenDom.innerHTML).toBe('

        Your name is:

        Please enter your name

        The <p> element has been rendered 5 time(s).

        Please enter your name

        The <p> element has been rendered 3 time(s).

        ') - - hiddenDom.querySelector("input")!.value = "X" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - hiddenDom.querySelector("input")!.value = "Xi" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - hiddenDom.querySelector("input")!.value = "Xin" - hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")) - await sleep(waitMsForDerivations) - - await sleep(waitMsForDerivations) - expect(hiddenDom.innerHTML).toBe('

        Your name is:

        Hello Xin

        The <p> element has been rendered 8 time(s).

        Hello Xin

        The <p> element has been rendered 4 time(s).

        ') - }) + hiddenDom.querySelector("input")!.value = "T"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + hiddenDom.querySelector("input")!.value = "Ta"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + hiddenDom.querySelector("input")!.value = "Tao"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '

        Your name is:

        Hello Tao

        The <p> element has been rendered 4 time(s).

        Hello Tao

        The <p> element has been rendered 2 time(s).

        ' + ); + + hiddenDom.querySelector("input")!.value = ""; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations * 2); + expect(hiddenDom.innerHTML).toBe( + '

        Your name is:

        Please enter your name

        The <p> element has been rendered 5 time(s).

        Please enter your name

        The <p> element has been rendered 3 time(s).

        ' + ); + + hiddenDom.querySelector("input")!.value = "X"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + hiddenDom.querySelector("input")!.value = "Xi"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + hiddenDom.querySelector("input")!.value = "Xin"; + hiddenDom.querySelector("input")!.dispatchEvent(new Event("input")); + await sleep(waitMsForDerivations); + + await sleep(waitMsForDerivations); + expect(hiddenDom.innerHTML).toBe( + '

        Your name is:

        Hello Xin

        The <p> element has been rendered 8 time(s).

        Hello Xin

        The <p> element has been rendered 4 time(s).

        ' + ); + }); it("should hydrate the given element", async () => { const stateProto = Object.getPrototypeOf(van.state()); @@ -2181,5 +2577,5 @@ describe("van", () => { expect(styledCounter.innerHTML).toBe(counterHTML(2, "🔼🔽")); expect(styledCounter !== prevStyledCounter); }); - }) + }); }); diff --git a/src/packages/dom/van.ts b/src/packages/dom/van.ts index 49a940e75..00c2182b4 100644 --- a/src/packages/dom/van.ts +++ b/src/packages/dom/van.ts @@ -452,8 +452,8 @@ const add = (dom: Element, ...children: readonly ChildDom[]): Element => { protoOfC === stateProto ? bind(() => c.val) : protoOfC === funcProto - ? bind(c) - : c; + ? bind(c) + : c; child != _undefined && dom.append(child); } return dom; @@ -494,8 +494,8 @@ const tag = (ns: string | null, name: string, ...args: any): Element => { dom.addEventListener(event, v); } : propSetter - ? propSetter.bind(dom) - : dom.setAttribute.bind(dom, k); + ? propSetter.bind(dom) + : dom.setAttribute.bind(dom, k); let protoOfV = protoOf(v ?? 0); From 3a46f5c53902b98301d75602023c882203904cdc Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 20 Aug 2024 09:33:21 +0100 Subject: [PATCH 10/59] Replace hint tooltip with the reactive version --- src/packages/hint/tooltip.ts | 104 +++--- src/packages/tooltip/placeTooltip.ts | 11 +- src/packages/tooltip/tooltip.ts | 432 ++++++++++++++++++++++++ src/packages/tooltip/tooltipPosition.ts | 152 +++++++++ src/util/getOffset.ts | 42 ++- tsconfig.json | 3 + 6 files changed, 680 insertions(+), 64 deletions(-) create mode 100644 src/packages/tooltip/tooltip.ts diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index d86d5b0e7..98447aaea 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -1,11 +1,8 @@ import { queryElement, queryElementByClassName } from "../../util/queryElement"; import { - arrowClassName, hintClassName, hintReferenceClassName, - tooltipClassName, tooltipReferenceLayerClassName, - tooltipTextClassName, } from "./className"; import { dataStepAttribute } from "./dataAttributes"; import { Hint } from "./hint"; @@ -13,8 +10,9 @@ import createElement from "../../util/createElement"; import { setClass } from "../../util/className"; import { hideHint } from "./hide"; import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; -import { placeTooltip } from "../../packages/tooltip"; import DOMEvent from "../../util/DOMEvent"; +import { Tooltip } from "../tooltip/tooltip"; +import getOffset from "../../util/getOffset"; // The hint close function used when the user clicks outside the hint let _hintCloseFunction: () => void | undefined; @@ -64,11 +62,52 @@ export async function showHintDialog(hint: Hint, stepId: number) { return; } - const tooltipLayer = createElement("div", { - className: tooltipClassName, + //setClass(tooltipTextLayer, tooltipTextClassName); + + //const tooltipWrapper = createElement("p"); + //tooltipWrapper.innerHTML = item.hint || ""; + //tooltipTextLayer.appendChild(tooltipWrapper); + + //if (hint.getOption("hintShowButton")) { + // const closeButton = createElement("a"); + // closeButton.className = hint.getOption("buttonClass"); + // closeButton.setAttribute("role", "button"); + // closeButton.innerHTML = hint.getOption("hintButtonLabel"); + // closeButton.onclick = () => hideHint(hint, stepId); + // tooltipTextLayer.appendChild(closeButton); + //} + + //setClass(arrowLayer, arrowClassName); + //tooltipLayer.appendChild(arrowLayer); + + //tooltipLayer.appendChild(tooltipTextLayer); + + const step = hintElement.getAttribute(dataStepAttribute) || ""; + + // set current step for _placeTooltip function + const hintItem = hint.getHint(parseInt(step, 10)); + + if (!hintItem) return; + + const tooltipLayer = Tooltip({ + position: hintItem.position, + text: item.hint || "", + targetOffset: getOffset(hintItem.element as HTMLElement), + // hints don't have step numbers + showStepNumbers: false, + hintMode: true, + + autoPosition: hint.getOption("autoPosition"), + positionPrecedence: hint.getOption("positionPrecedence"), + + closeButtonEnabled: hint.getOption("hintShowButton"), + closeButtonLabel: hint.getOption("hintButtonLabel"), + closeButtonClassName: hint.getOption("buttonClass"), + closeButtonOnClick: () => hideHint(hint, stepId), }); - const tooltipTextLayer = createElement("div"); - const arrowLayer = createElement("div"); + + //const tooltipTextLayer = createElement("div"); + //const arrowLayer = createElement("div"); const referenceLayer = createElement("div"); tooltipLayer.onclick = (e: Event) => { @@ -82,33 +121,6 @@ export async function showHintDialog(hint: Hint, stepId: number) { } }; - setClass(tooltipTextLayer, tooltipTextClassName); - - const tooltipWrapper = createElement("p"); - tooltipWrapper.innerHTML = item.hint || ""; - tooltipTextLayer.appendChild(tooltipWrapper); - - if (hint.getOption("hintShowButton")) { - const closeButton = createElement("a"); - closeButton.className = hint.getOption("buttonClass"); - closeButton.setAttribute("role", "button"); - closeButton.innerHTML = hint.getOption("hintButtonLabel"); - closeButton.onclick = () => hideHint(hint, stepId); - tooltipTextLayer.appendChild(closeButton); - } - - setClass(arrowLayer, arrowClassName); - tooltipLayer.appendChild(arrowLayer); - - tooltipLayer.appendChild(tooltipTextLayer); - - const step = hintElement.getAttribute(dataStepAttribute) || ""; - - // set current step for _placeTooltip function - const hintItem = hint.getHint(parseInt(step, 10)); - - if (!hintItem) return; - // align reference layer position setClass( referenceLayer, @@ -129,17 +141,17 @@ export async function showHintDialog(hint: Hint, stepId: number) { document.body.appendChild(referenceLayer); // set proper position - placeTooltip( - tooltipLayer, - arrowLayer, - hintItem.element as HTMLElement, - hintItem.position, - hint.getOption("positionPrecedence"), - // hints don't have step numbers - false, - hint.getOption("autoPosition"), - hintItem.tooltipClass ?? hint.getOption("tooltipClass") - ); + //placeTooltip( + // tooltipLayer, + // arrowLayer, + // hintItem.element as HTMLElement, + // hintItem.position, + // hint.getOption("positionPrecedence"), + // // hints don't have step numbers + // false, + // hint.getOption("autoPosition"), + // hintItem.tooltipClass ?? hint.getOption("tooltipClass") + //); _hintCloseFunction = () => { removeHintTooltip(); diff --git a/src/packages/tooltip/placeTooltip.ts b/src/packages/tooltip/placeTooltip.ts index a3b7a11bd..08b02a24c 100644 --- a/src/packages/tooltip/placeTooltip.ts +++ b/src/packages/tooltip/placeTooltip.ts @@ -174,13 +174,6 @@ export const placeTooltip = ( width: number; height: number; }; - let targetOffset: { - top: number; - left: number; - width: number; - height: number; - }; - let windowSize: { width: number; height: number }; //reset the old style tooltipLayer.style.top = ""; @@ -207,9 +200,9 @@ export const placeTooltip = ( } let tooltipLayerStyleLeft: number; - targetOffset = getOffset(targetElement as HTMLElement); + let targetOffset = getOffset(targetElement as HTMLElement); tooltipOffset = getOffset(tooltipLayer); - windowSize = getWindowSize(); + let windowSize = getWindowSize(); addClass(tooltipLayer, `introjs-${position}`); diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts new file mode 100644 index 000000000..20cfc9ca0 --- /dev/null +++ b/src/packages/tooltip/tooltip.ts @@ -0,0 +1,432 @@ +import getOffset, { Offset } from "../../util/getOffset"; +import getWindowSize from "../../util/getWindowSize"; +import van, { State } from "../dom/van"; +import { + arrowClassName, + tooltipClassName, + tooltipTextClassName, +} from "../tour/classNames"; +import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; + +const { div, p, a } = van.tags; + +export const TooltipArrow = (props: { + tooltipPosition: State; + tooltipBottomOverflow: State; +}) => { + const classNames = van.derive(() => { + const classNames = [arrowClassName]; + + switch (props.tooltipPosition.val) { + case "top-right-aligned": + classNames.push("bottom-right"); + break; + + case "top-middle-aligned": + classNames.push("bottom-middle"); + break; + + case "top-left-aligned": + // top-left-aligned is the same as the default top + case "top": + classNames.push("bottom"); + break; + case "right": + if (props.tooltipBottomOverflow) { + // In this case, right would have fallen below the bottom of the screen. + // Modify so that the bottom of the tooltip connects with the target + classNames.push("left-bottom"); + } else { + classNames.push("left"); + } + break; + case "left": + if (props.tooltipBottomOverflow) { + // In this case, left would have fallen below the bottom of the screen. + // Modify so that the bottom of the tooltip connects with the target + classNames.push("right-bottom"); + } else { + classNames.push("right"); + } + + break; + case "floating": + // no arrow element for floating tooltips + break; + case "bottom-right-aligned": + classNames.push("top-right"); + break; + + case "bottom-middle-aligned": + classNames.push("top-middle"); + break; + + // case 'bottom-left-aligned': + // Bottom-left-aligned is the same as the default bottom + // case 'bottom': + // Bottom going to follow the default behavior + default: + classNames.push("top"); + } + + return classNames; + }); + + return div({ + className: () => classNames.val?.filter(Boolean).join(" "), + style: () => + `display: ${ + props.tooltipPosition.val === "floating" ? "none" : "block" + };`, + }); +}; + +/** + * Set tooltip right so it doesn't go off the left side of the window + * + * @return boolean true, if tooltipLayerStyleRight is ok. false, otherwise. + */ +function checkLeft( + targetOffset: { + top: number; + left: number; + width: number; + height: number; + }, + tooltipLayerStyleRight: number, + tooltipWidth: number, + tooltipLeft: State, + tooltipRight: State +): boolean { + if ( + targetOffset.left + + targetOffset.width - + tooltipLayerStyleRight - + tooltipWidth < + 0 + ) { + // off the left side of the window + tooltipLeft.val = `-${targetOffset.left}px`; + return false; + } + tooltipRight.val = `${tooltipLayerStyleRight}px`; + return true; +} + +/** + * Set tooltip left so it doesn't go off the right side of the window + * + * @return boolean true, if tooltipLayerStyleLeft is ok. false, otherwise. + */ +function checkRight( + targetOffset: { + top: number; + left: number; + width: number; + height: number; + }, + tooltipLayerStyleLeft: number, + tooltipWidth: number, + tooltipLeft: State +): boolean { + const windowSize = getWindowSize(); + + if ( + targetOffset.left + tooltipLayerStyleLeft + tooltipWidth > + windowSize.width + ) { + // off the right side of the window + tooltipLeft.val = `${ + windowSize.width - tooltipWidth - targetOffset.left + }px`; + return false; + } + + tooltipLeft.val = `${tooltipLayerStyleLeft}px`; + return true; +} + +const alignTooltip = ( + position: TooltipPosition, + targetOffset: { width: number; height: number; left: number; top: number }, + tooltipWidth: number, + tooltipHeight: number, + tooltipTop: State, + tooltipBottom: State, + tooltipLeft: State, + tooltipRight: State, + tooltipMarginLeft: State, + tooltipMarginTop: State, + tooltipBottomOverflow: State, + showStepNumbers: boolean, + hintMode: boolean +) => { + tooltipTop.val = "initial"; + tooltipBottom.val = "initial"; + tooltipLeft.val = "initial"; + tooltipRight.val = "initial"; + tooltipMarginLeft.val = "initial"; + tooltipMarginTop.val = "initial"; + + let tooltipLayerStyleLeftRight = targetOffset.width / 2 - tooltipWidth / 2; + + switch (position) { + case "top-right-aligned": + let tooltipLayerStyleRight = 0; + checkLeft( + targetOffset, + tooltipLayerStyleRight, + tooltipWidth, + tooltipLeft, + tooltipRight + ); + tooltipBottom.val = `${targetOffset.height + 20}px`; + break; + + case "top-middle-aligned": + // a fix for middle aligned hints + if (hintMode) { + tooltipLayerStyleLeftRight += 5; + } + + if ( + checkLeft( + targetOffset, + tooltipLayerStyleLeftRight, + tooltipWidth, + tooltipLeft, + tooltipRight + ) + ) { + tooltipRight.val = undefined; + checkRight( + targetOffset, + tooltipLayerStyleLeftRight, + tooltipWidth, + tooltipLeft + ); + } + tooltipBottom.val = `${targetOffset.height + 20}px`; + break; + + case "top-left-aligned": + // top-left-aligned is the same as the default top + case "top": + const tooltipLayerStyleLeft = hintMode ? 0 : 15; + + checkRight( + targetOffset, + tooltipLayerStyleLeft, + tooltipWidth, + tooltipLeft + ); + tooltipBottom.val = `${targetOffset.height + 20}px`; + break; + case "right": + tooltipLeft.val = `${targetOffset.width + 20}px`; + + if (tooltipBottomOverflow) { + // In this case, right would have fallen below the bottom of the screen. + // Modify so that the bottom of the tooltip connects with the target + tooltipTop.val = `-${tooltipHeight - targetOffset.height - 20}px`; + } + break; + case "left": + if (!hintMode && showStepNumbers === true) { + tooltipTop.val = "15px"; + } + + if (tooltipBottomOverflow) { + // In this case, left would have fallen below the bottom of the screen. + // Modify so that the bottom of the tooltip connects with the target + tooltipTop.val = `-${tooltipHeight - targetOffset.height - 20}px`; + } + tooltipRight.val = `${targetOffset.width + 20}px`; + + break; + case "floating": + //we have to adjust the top and left of layer manually for intro items without element + tooltipLeft.val = "50%"; + tooltipTop.val = "50%"; + tooltipMarginLeft.val = `-${tooltipWidth / 2}px`; + tooltipMarginTop.val = `-${tooltipHeight / 2}px`; + + break; + case "bottom-right-aligned": + tooltipLayerStyleRight = 0; + checkLeft( + targetOffset, + tooltipLayerStyleRight, + tooltipWidth, + tooltipLeft, + tooltipRight + ); + tooltipTop.val = `${targetOffset.height + 20}px`; + break; + + case "bottom-middle-aligned": + // a fix for middle aligned hints + if (hintMode) { + tooltipLayerStyleLeftRight += 5; + } + + if ( + checkLeft( + targetOffset, + tooltipLayerStyleLeftRight, + tooltipWidth, + tooltipLeft, + tooltipRight + ) + ) { + tooltipRight.val = ""; + checkRight( + targetOffset, + tooltipLayerStyleLeftRight, + tooltipWidth, + tooltipLeft + ); + } + tooltipTop.val = `${targetOffset.height + 20}px`; + break; + + // case 'bottom-left-aligned': + // Bottom-left-aligned is the same as the default bottom + // case 'bottom': + // Bottom going to follow the default behavior + default: + checkRight(targetOffset, 0, tooltipWidth, tooltipLeft); + tooltipTop.val = `${targetOffset.height + 20}px`; + } +}; + +type TooltipProps = { + position: TooltipPosition; + text: string; + targetOffset: Offset; + hintMode: boolean; + showStepNumbers: boolean; + + // auto-alignment properties + autoPosition: boolean; + positionPrecedence: TooltipPosition[]; + + closeButtonEnabled: boolean; + closeButtonOnClick: () => void; + closeButtonLabel: string; + closeButtonClassName: string; +}; + +export const Tooltip = ({ + position: initialPosition, + text, + targetOffset, + hintMode = false, + showStepNumbers = false, + + // auto-alignment properties + autoPosition = true, + positionPrecedence = [], + + closeButtonEnabled, + closeButtonOnClick, + closeButtonLabel, + closeButtonClassName, +}: TooltipProps) => { + const position = van.state(initialPosition); + const top = van.state("auto"); + const right = van.state("auto"); + const bottom = van.state("auto"); + const left = van.state("auto"); + const marginLeft = van.state("auto"); + const marginTop = van.state("auto"); + // setting a default height for the tooltip instead of 0 to avoid flickering + const tooltipHeight = van.state(150); + // max width of the tooltip according to its CSS class + const tooltipWidth = van.state(300); + const windowSize = getWindowSize(); + const tooltipBottomOverflow = van.derive( + () => targetOffset.top + tooltipHeight.val! > windowSize.height + ); + + van.derive(() => { + if ( + position.val !== "floating" && + autoPosition && + tooltipWidth.val && + tooltipHeight.val && + position.val !== undefined + ) { + position.val = determineAutoPosition( + positionPrecedence, + targetOffset, + tooltipWidth.val, + tooltipHeight.val, + position.val + ); + } + }); + + van.derive(() => { + if ( + tooltipWidth.val !== undefined && + tooltipHeight.val !== undefined && + tooltipBottomOverflow.val !== undefined && + position.val !== undefined + ) { + alignTooltip( + position.val, + targetOffset, + tooltipWidth.val, + tooltipHeight.val, + top, + bottom, + left, + right, + marginLeft, + marginTop, + tooltipBottomOverflow, + showStepNumbers, + hintMode + ); + } + }); + + const tooltip = div( + { + style: () => + `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};`, + className: () => `${tooltipClassName} introjs-${position.val}`, + role: "dialog", + }, + [ + TooltipArrow({ + tooltipPosition: position, + tooltipBottomOverflow: tooltipBottomOverflow, + }), + div({ className: tooltipTextClassName }, [ + p(text), + closeButtonEnabled ?? + a( + { + className: closeButtonClassName, + role: "button", + onclick: closeButtonOnClick, + }, + closeButtonLabel + ), + ]), + ] + ); + + // wait for the tooltip to be rendered before calculating the position + setTimeout(() => { + if (tooltip) { + const tooltipOffset = getOffset(tooltip); + tooltipHeight.val = tooltipOffset.height; + tooltipWidth.val = tooltipOffset.width; + } + }, 1); + + return tooltip; +}; diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts index a42c6add7..db4cbb89f 100644 --- a/src/packages/tooltip/tooltipPosition.ts +++ b/src/packages/tooltip/tooltipPosition.ts @@ -1,3 +1,7 @@ +import getWindowSize from "../../util/getWindowSize"; +import removeEntry from "../../util/removeEntry"; +import getOffset, { Offset } from "../../util/getOffset"; + export type TooltipPosition = | "floating" | "top" @@ -10,3 +14,151 @@ export type TooltipPosition = | "bottom-right-aligned" | "bottom-left-aligned" | "bottom-middle-aligned"; + +/** + * auto-determine alignment + */ +export function determineAutoAlignment( + offsetLeft: number, + tooltipWidth: number, + windowWidth: number, + desiredAlignment: TooltipPosition[] +): TooltipPosition | null { + const halfTooltipWidth = tooltipWidth / 2; + const winWidth = Math.min(windowWidth, window.screen.width); + + // valid left must be at least a tooltipWidth + // away from right side + if (winWidth - offsetLeft < tooltipWidth) { + removeEntry(desiredAlignment, "top-left-aligned"); + removeEntry(desiredAlignment, "bottom-left-aligned"); + } + + // valid middle must be at least half + // width away from both sides + if ( + offsetLeft < halfTooltipWidth || + winWidth - offsetLeft < halfTooltipWidth + ) { + removeEntry(desiredAlignment, "top-middle-aligned"); + removeEntry(desiredAlignment, "bottom-middle-aligned"); + } + + // valid right must be at least a tooltipWidth + // width away from left side + if (offsetLeft < tooltipWidth) { + removeEntry(desiredAlignment, "top-right-aligned"); + removeEntry(desiredAlignment, "bottom-right-aligned"); + } + + if (desiredAlignment.length) { + return desiredAlignment[0]; + } + + return null; +} + +/** + * Determines the position of the tooltip based on the position precedence and availability + * of screen space. + */ +export function determineAutoPosition( + positionPrecedence: TooltipPosition[], + targetOffset: Offset, + tooltipWidth: number, + tooltipHeight: number, + desiredTooltipPosition: TooltipPosition +): TooltipPosition { + // Take a clone of position precedence. These will be the available + const possiblePositions = positionPrecedence.slice(); + + const windowSize = getWindowSize(); + + // Add some padding to the tooltip height and width for better positioning + tooltipHeight = tooltipHeight + 10; + tooltipWidth = tooltipWidth + 20; + + // If we check all the possible areas, and there are no valid places for the tooltip, the element + // must take up most of the screen real estate. Show the tooltip floating in the middle of the screen. + let calculatedPosition: TooltipPosition = "floating"; + + /* + * auto determine position + */ + + // Check for space below + if (targetOffset.absoluteBottom + tooltipHeight > windowSize.height) { + removeEntry(possiblePositions, "bottom"); + } + + // Check for space above + if (targetOffset.absoluteTop - tooltipHeight < 0) { + removeEntry(possiblePositions, "top"); + } + + // Check for space to the right + if (targetOffset.absoluteRight + tooltipWidth > windowSize.width) { + removeEntry(possiblePositions, "right"); + } + + // Check for space to the left + if (targetOffset.absoluteLeft - tooltipWidth < 0) { + removeEntry(possiblePositions, "left"); + } + + // strip alignment from position + if (desiredTooltipPosition) { + // ex: "bottom-right-aligned" + // should return 'bottom' + desiredTooltipPosition = desiredTooltipPosition.split( + "-" + )[0] as TooltipPosition; + } + + if (possiblePositions.length) { + // Pick the first valid position, in order + calculatedPosition = possiblePositions[0]; + + if (possiblePositions.includes(desiredTooltipPosition)) { + // If the requested position is in the list, choose that + calculatedPosition = desiredTooltipPosition; + } + } + + // only "top" and "bottom" positions have optional alignments + if (calculatedPosition === "top" || calculatedPosition === "bottom") { + let defaultAlignment: TooltipPosition; + let desiredAlignment: TooltipPosition[] = []; + + if (calculatedPosition === "top") { + // if screen width is too small + // for ANY alignment, middle is + // probably the best for visibility + defaultAlignment = "top-middle-aligned"; + + desiredAlignment = [ + "top-left-aligned", + "top-middle-aligned", + "top-right-aligned", + ]; + } else { + defaultAlignment = "bottom-middle-aligned"; + + desiredAlignment = [ + "bottom-left-aligned", + "bottom-middle-aligned", + "bottom-right-aligned", + ]; + } + + calculatedPosition = + determineAutoAlignment( + targetOffset.absoluteLeft, + tooltipWidth, + windowSize.width, + desiredAlignment + ) || defaultAlignment; + } + + return calculatedPosition; +} diff --git a/src/util/getOffset.ts b/src/util/getOffset.ts index 1b454f585..81469316d 100644 --- a/src/util/getOffset.ts +++ b/src/util/getOffset.ts @@ -1,8 +1,21 @@ import getPropValue from "./getPropValue"; import isFixed from "./isFixed"; +export type Offset = { + width: number; + height: number; + left: number; + right: number; + top: number; + bottom: number; + absoluteTop: number; + absoluteLeft: number; + absoluteRight: number; + absoluteBottom: number; +}; + /** - * Get an element position on the page relative to another element (or body) + * Get an element position on the page relative to another element (or body) including scroll offset * Thanks to `meouw`: http://stackoverflow.com/a/442474/375966 * * @api private @@ -11,7 +24,7 @@ import isFixed from "./isFixed"; export default function getOffset( element: HTMLElement, relativeEl?: HTMLElement -): { width: number; height: number; left: number; top: number } { +): Offset { const body = document.body; const docEl = document.documentElement; const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; @@ -23,10 +36,7 @@ export default function getOffset( const xr = relativeEl.getBoundingClientRect(); const relativeElPosition = getPropValue(relativeEl, "position"); - let obj = { - width: x.width, - height: x.height, - }; + let obj: { top: number; left: number } = { top: 0, left: 0 }; if ( (relativeEl.tagName.toLowerCase() !== "body" && @@ -35,21 +45,35 @@ export default function getOffset( ) { // when the container of our target element is _not_ body and has either "relative" or "sticky" position, we should not // consider the scroll position but we need to include the relative x/y of the container element - return Object.assign(obj, { + obj = Object.assign(obj, { top: x.top - xr.top, left: x.left - xr.left, }); } else { if (isFixed(element)) { - return Object.assign(obj, { + obj = Object.assign(obj, { top: x.top, left: x.left, }); } else { - return Object.assign(obj, { + obj = Object.assign(obj, { top: x.top + scrollTop, left: x.left + scrollLeft, }); } } + + return { + ...obj, + ...{ + width: x.width, + height: x.height, + bottom: obj.top + x.height, + right: obj.left + x.width, + absoluteTop: x.top, + absoluteLeft: x.left, + absoluteBottom: x.bottom, + absoluteRight: x.right, + }, + }; } diff --git a/tsconfig.json b/tsconfig.json index 7dcf0bff4..3dd0924af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "compilerOptions": { "target": "es2015", "allowSyntheticDefaultImports": true, + "moduleResolution": "Bundler", + "importHelpers": true, + "resolveJsonModule": true, "allowJs": true, "alwaysStrict": true, "sourceMap": true, From c58d62e6a34aae9b3f657184ca7c437ad820192c Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 1 Sep 2024 11:53:07 +0100 Subject: [PATCH 11/59] HintTooltip --- src/packages/hint/hintTooltip.ts | 37 ++++++++++++++++++++++ src/packages/hint/tooltip.ts | 5 ++- src/packages/tooltip/tooltip.ts | 54 +++++++++++--------------------- 3 files changed, 57 insertions(+), 39 deletions(-) create mode 100644 src/packages/hint/hintTooltip.ts diff --git a/src/packages/hint/hintTooltip.ts b/src/packages/hint/hintTooltip.ts new file mode 100644 index 000000000..1ac8469e4 --- /dev/null +++ b/src/packages/hint/hintTooltip.ts @@ -0,0 +1,37 @@ +import { Tooltip, TooltipProps } from "../tooltip/tooltip"; +import van from "../dom/van"; + +const { a } = van.tags; + +export type HintTooltipProps = Omit & { + closeButtonEnabled: boolean; + closeButtonOnClick: () => void; + closeButtonLabel: string; + closeButtonClassName: string; +}; + +export const HintTooltip = ({ + closeButtonEnabled, + closeButtonOnClick, + closeButtonLabel, + closeButtonClassName, + ...props +}: HintTooltipProps) => { + return Tooltip( + { + ...props, + hintMode: true, + }, + [ + closeButtonEnabled ? + a( + { + className: closeButtonClassName, + role: "button", + onclick: closeButtonOnClick, + }, + closeButtonLabel + ) : null, + ] + ); +}; diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index 98447aaea..215a19ae6 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -11,8 +11,8 @@ import { setClass } from "../../util/className"; import { hideHint } from "./hide"; import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; import DOMEvent from "../../util/DOMEvent"; -import { Tooltip } from "../tooltip/tooltip"; import getOffset from "../../util/getOffset"; +import { HintTooltip } from "./hintTooltip"; // The hint close function used when the user clicks outside the hint let _hintCloseFunction: () => void | undefined; @@ -89,13 +89,12 @@ export async function showHintDialog(hint: Hint, stepId: number) { if (!hintItem) return; - const tooltipLayer = Tooltip({ + const tooltipLayer = HintTooltip({ position: hintItem.position, text: item.hint || "", targetOffset: getOffset(hintItem.element as HTMLElement), // hints don't have step numbers showStepNumbers: false, - hintMode: true, autoPosition: hint.getOption("autoPosition"), positionPrecedence: hint.getOption("positionPrecedence"), diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 20cfc9ca0..47d17a179 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -1,6 +1,6 @@ import getOffset, { Offset } from "../../util/getOffset"; import getWindowSize from "../../util/getWindowSize"; -import van, { State } from "../dom/van"; +import van, { ChildDom, State } from "../dom/van"; import { arrowClassName, tooltipClassName, @@ -8,7 +8,7 @@ import { } from "../tour/classNames"; import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; -const { div, p, a } = van.tags; +const { div, p } = van.tags; export const TooltipArrow = (props: { tooltipPosition: State; @@ -300,7 +300,7 @@ const alignTooltip = ( } }; -type TooltipProps = { +export type TooltipProps = { position: TooltipPosition; text: string; targetOffset: Offset; @@ -310,29 +310,22 @@ type TooltipProps = { // auto-alignment properties autoPosition: boolean; positionPrecedence: TooltipPosition[]; - - closeButtonEnabled: boolean; - closeButtonOnClick: () => void; - closeButtonLabel: string; - closeButtonClassName: string; }; -export const Tooltip = ({ - position: initialPosition, - text, - targetOffset, - hintMode = false, - showStepNumbers = false, - - // auto-alignment properties - autoPosition = true, - positionPrecedence = [], - - closeButtonEnabled, - closeButtonOnClick, - closeButtonLabel, - closeButtonClassName, -}: TooltipProps) => { +export const Tooltip = ( + { + position: initialPosition, + text, + targetOffset, + hintMode = false, + showStepNumbers = false, + + // auto-alignment properties + autoPosition = true, + positionPrecedence = [], + }: TooltipProps, + children?: ChildDom[] +) => { const position = van.state(initialPosition); const top = van.state("auto"); const right = van.state("auto"); @@ -404,18 +397,7 @@ export const Tooltip = ({ tooltipPosition: position, tooltipBottomOverflow: tooltipBottomOverflow, }), - div({ className: tooltipTextClassName }, [ - p(text), - closeButtonEnabled ?? - a( - { - className: closeButtonClassName, - role: "button", - onclick: closeButtonOnClick, - }, - closeButtonLabel - ), - ]), + div({ className: tooltipTextClassName }, [p(text), children]), ] ); From c0781826d1605172243b18fef3d35746de79a26b Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 1 Sep 2024 19:47:03 +0100 Subject: [PATCH 12/59] tourTooltip --- src/packages/hint/hintTooltip.ts | 25 +- src/packages/tooltip/tooltip.ts | 6 +- src/packages/tour/tourTooltip.ts | 443 +++++++++++++++++++++++++++++++ 3 files changed, 460 insertions(+), 14 deletions(-) create mode 100644 src/packages/tour/tourTooltip.ts diff --git a/src/packages/hint/hintTooltip.ts b/src/packages/hint/hintTooltip.ts index 1ac8469e4..918758ba6 100644 --- a/src/packages/hint/hintTooltip.ts +++ b/src/packages/hint/hintTooltip.ts @@ -1,9 +1,11 @@ import { Tooltip, TooltipProps } from "../tooltip/tooltip"; import van from "../dom/van"; +import { tooltipTextClassName } from "./className"; -const { a } = van.tags; +const { a, p, div } = van.tags; export type HintTooltipProps = Omit & { + text: string; closeButtonEnabled: boolean; closeButtonOnClick: () => void; closeButtonLabel: string; @@ -11,6 +13,7 @@ export type HintTooltipProps = Omit & { }; export const HintTooltip = ({ + text, closeButtonEnabled, closeButtonOnClick, closeButtonLabel, @@ -23,15 +26,17 @@ export const HintTooltip = ({ hintMode: true, }, [ - closeButtonEnabled ? - a( - { - className: closeButtonClassName, - role: "button", - onclick: closeButtonOnClick, - }, - closeButtonLabel - ) : null, + div({ className: tooltipTextClassName }, p(text)), + closeButtonEnabled + ? a( + { + className: closeButtonClassName, + role: "button", + onclick: closeButtonOnClick, + }, + closeButtonLabel + ) + : null, ] ); }; diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 47d17a179..073c62205 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -8,7 +8,7 @@ import { } from "../tour/classNames"; import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; -const { div, p } = van.tags; +const { div } = van.tags; export const TooltipArrow = (props: { tooltipPosition: State; @@ -302,7 +302,6 @@ const alignTooltip = ( export type TooltipProps = { position: TooltipPosition; - text: string; targetOffset: Offset; hintMode: boolean; showStepNumbers: boolean; @@ -315,7 +314,6 @@ export type TooltipProps = { export const Tooltip = ( { position: initialPosition, - text, targetOffset, hintMode = false, showStepNumbers = false, @@ -397,7 +395,7 @@ export const Tooltip = ( tooltipPosition: position, tooltipBottomOverflow: tooltipBottomOverflow, }), - div({ className: tooltipTextClassName }, [p(text), children]), + [children], ] ); diff --git a/src/packages/tour/tourTooltip.ts b/src/packages/tour/tourTooltip.ts new file mode 100644 index 000000000..07dab220e --- /dev/null +++ b/src/packages/tour/tourTooltip.ts @@ -0,0 +1,443 @@ +import { Tooltip, type TooltipProps } from "../tooltip/tooltip"; +import van from "../dom/van"; +import { + activeClassName, + bulletsClassName, + disabledButtonClassName, + doneButtonClassName, + dontShowAgainClassName, + fullButtonClassName, + helperNumberLayerClassName, + nextButtonClassName, + previousButtonClassName, + progressBarClassName, + progressClassName, + skipButtonClassName, + tooltipButtonsClassName, + tooltipHeaderClassName, + tooltipTextClassName, + tooltipTitleClassName, +} from "./classNames"; +import { TourStep } from "./steps"; +import { dataStepNumberAttribute } from "./dataAttributes"; + +const { h1, div, input, label, ul, li, a, p } = van.tags; + +const DontShowAgain = ({ + dontShowAgainLabel, + onDontShowAgainChange, +}: { + dontShowAgainLabel: string; + onDontShowAgainChange: (checked: boolean) => void; +}) => { + return div({ className: dontShowAgainClassName }, [ + input({ + type: "checkbox", + id: dontShowAgainClassName, + name: dontShowAgainClassName, + onchange: (e: any) => { + onDontShowAgainChange((e.target as HTMLInputElement).checked); + }, + }), + label({ for: dontShowAgainClassName }, dontShowAgainLabel), + ]); +}; + +const Bullets = ({ + steps, + step, + onBulletClick, +}: { + steps: TourStep[]; + step: TourStep; + onBulletClick: (stepNumber: number) => void; +}): HTMLElement => { + return div({ className: bulletsClassName }, [ + ul({ role: "tablist" }, [ + ...steps.map(({ step: stepNumber }) => { + const innerLi = li( + { + role: "presentation", + className: `${ + step.step === stepNumber + } ? ${activeClassName} : ""`, + }, + [ + a({ + role: "tab", + onclick: (e: any) => { + const stepNumberAttribute = ( + e.target as HTMLElement + ).getAttribute(dataStepNumberAttribute); + if (!stepNumberAttribute) return; + + onBulletClick(parseInt(stepNumberAttribute, 10)); + }, + innerHTML: " ", + [dataStepNumberAttribute]: stepNumber, + }), + ] + ); + + return innerLi; + }), + ]), + ]); +}; + +const ProgressBar = ({ + steps, + currentStep, + progressBarAdditionalClass +}: { + steps: TourStep[]; + currentStep: number; + progressBarAdditionalClass: string; +}) => { + const progress = ((currentStep) / steps.length) * 100; + + return div({ className: progressClassName }, [ + div({ + className: `${progressBarClassName} ${progressBarAdditionalClass ? progressBarAdditionalClass : ""}`, + "role": "progress", + "aria-valuemin": "0", + "aria-valuemax": "100", + "aria-valuenow": () => progress.toString(), + style: () => `width:${progress}%;`, + }), + ]); +} + +const StepNumber = ({ + step, + steps, + stepNumbersOfLabel, +}: { + step: TourStep; + steps: TourStep[]; + stepNumbersOfLabel: string; +}) => { + return div({ className: helperNumberLayerClassName }, [ + `${step.step} ${stepNumbersOfLabel} ${steps.length}`, + ]); +}; + +const Button = ({ + label, + onClick, + className +}: { + label: string; + onClick: (e: any) => void; + className?: string +}) => { + return a( + { + role: "button", + tabIndex: 0, + onclick: onClick, + className: className ?? "", + }, + [label] + ); +}; + +const NextButton = ({ + label, + onClick, + isDisabled, + isFullButton, + isDoneButton, + buttonClass +}: { + label: string; + onClick: (e: any) => void; + isFullButton: boolean; + isDisabled: boolean; + // next button can be a done button as well + isDoneButton: boolean; + buttonClass: string; +}) => { + const classNames = [buttonClass]; + + if (isDoneButton) { + classNames.push(doneButtonClassName); + } else { + classNames.push(nextButtonClassName); + } + + if (isDisabled) { + classNames.push(disabledButtonClassName); + } + + if (isFullButton) { + classNames.push(fullButtonClassName); + } + + return Button({ label, onClick, className: classNames.filter(Boolean).join(" ") }); +} + +const PrevButton = ({ + label, + onClick, + isFullButton, + isDisabled, + buttonClass +}: { + label: string; + onClick: (e: any) => void; + isFullButton: boolean; + isDisabled: boolean; + buttonClass: string; +}) => { + const classNames = [buttonClass, previousButtonClassName]; + + if (isFullButton) { + classNames.push(fullButtonClassName); + } + + if (isDisabled) { + classNames.push(disabledButtonClassName); + } + + return Button({ label, onClick, className: classNames.filter(Boolean).join(" ") }); +} + +const Buttons = ({ + steps, + currentStep, + + buttonClass, + + nextToDone, + doneLabel, + + hideNext, + nextLabel, + onNextClick, + + hidePrev, + prevLabel, + onPrevClick, +}: { + steps: TourStep[]; + currentStep: number; + + buttonClass: string; + + nextToDone: boolean; + doneLabel: string; + + hideNext: boolean; + nextLabel: string; + onNextClick: (e: any) => void; + + hidePrev: boolean; + prevLabel: string; + onPrevClick: (e: any) => void; +}) => { + const children: ChildNode[] = []; + + let shouldShowPrev = steps.length > 1; + let isPrevButtonDisabled = false; + let isPrevButtonFull = false; + + let shouldShowNext = true; + let shouldRenderNextAsDone = false; + let isNextButtonFull = false; + let isNextButtonDisabled = false; + + // when the current step is the first one and there are more steps to show + if (currentStep === 0 && steps.length > 1) { + if (hidePrev) { + shouldShowPrev = false; + isNextButtonFull = true; + } else { + isPrevButtonDisabled = true + } + } else if (currentStep === steps.length - 1 || steps.length === 1) { + // when the current step is the last one or there is only one step to show + if (hideNext) { + shouldShowNext = false; + isPrevButtonFull = true; + } else { + if (nextToDone) { + shouldRenderNextAsDone = true; + } else { + isNextButtonDisabled = true; + } + } + } + + // in order to prevent always displaying the previous button + if (shouldShowPrev) { + children.push( + PrevButton({ + label: prevLabel, + onClick: onPrevClick, + isDisabled: isPrevButtonDisabled, + isFullButton: isPrevButtonFull, + buttonClass, + }) + ); + } + + if (shouldShowNext) { + children.push( + NextButton({ + label: shouldRenderNextAsDone ? doneLabel : nextLabel, + isDoneButton: shouldRenderNextAsDone, + onClick: onNextClick, + isDisabled: isNextButtonDisabled, + isFullButton: isNextButtonFull, + buttonClass, + }) + ); + } + + return div({ className: tooltipButtonsClassName }, children); +}; + +const Header = ({ + title, + + skipLabel, + onSkipClick, +}: { + title: string; + + skipLabel: string; + onSkipClick: (e: any) => void; +}) => { + return div({ className: tooltipHeaderClassName }, [ + h1({ className: tooltipTitleClassName }, title), + Button({ className: skipButtonClassName, label: skipLabel, onClick: onSkipClick }), + ]); +}; + +export type TourTooltipProps = Omit & { + title: string; + text: string; + + steps: TourStep[]; + step: TourStep; + currentStep: number; + + bullets: boolean; + onBulletClick: (stepNumber: number) => void; + + buttons: boolean; + nextLabel: string; + onNextClick: (e: any) => void; + prevLabel: string; + onPrevClick: (e: any) => void; + skipLabel: string, + onSkipClick: (e: any) => void; + buttonClass: string; + nextToDone: boolean; + doneLabel: string; + hideNext: boolean; + hidePrev: boolean; + + progress: boolean; + progressBarAdditionalClass: string; + + stepNumbers: boolean; + stepNumbersOfLabel: string; + + dontShowAgain: boolean; + dontShowAgainLabel: string; + onDontShowAgainChange: (checked: boolean) => void; +}; + +export const TourTooltip = ({ + steps, + step, + currentStep, + + onBulletClick, + + title, + text, + + bullets, + + buttons, + nextLabel, + onNextClick, + prevLabel, + onPrevClick, + skipLabel, + onSkipClick, + buttonClass, + nextToDone, + doneLabel, + hideNext, + hidePrev, + + progress, + progressBarAdditionalClass, + + stepNumbers, + stepNumbersOfLabel, + + dontShowAgain, + onDontShowAgainChange, + dontShowAgainLabel, + ...props +}: TourTooltipProps) => { + const children = []; + + children.push(Header({ title, skipLabel, onSkipClick })); + + children.push( + div({ className: tooltipTextClassName }, p(text)), +); + + if (dontShowAgain) { + children.push(DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange })); + } + + if (bullets) { + children.push(Bullets({ steps, step, onBulletClick })); + } + + if (progress) { + children.push( + ProgressBar({ steps, currentStep, progressBarAdditionalClass }) + ); + } + + if (stepNumbers) { + children.push(StepNumber({ step, steps, stepNumbersOfLabel })); + } + + if (buttons) { + children.push( + Buttons({ + steps, + currentStep, + + nextLabel: nextLabel, + onNextClick: onNextClick, + + prevLabel: prevLabel, + onPrevClick: onPrevClick, + + buttonClass, + nextToDone, + doneLabel, + hideNext, + hidePrev, + }) + ); + } + + return Tooltip( + { + ...props, + hintMode: false, + }, + children + ); +}; From da7fc24e4fad0f589ff495a4a91550cf4c434eab Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 1 Sep 2024 22:20:01 +0100 Subject: [PATCH 13/59] complete tourTooltip --- src/packages/hint/tooltip.ts | 5 +- src/packages/tooltip/tooltip.ts | 14 +- src/packages/tour/showElement.ts | 392 +++++-------------------------- src/packages/tour/tour.ts | 4 + src/packages/tour/tourTooltip.ts | 281 +++++++++++++--------- 5 files changed, 242 insertions(+), 454 deletions(-) diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index 215a19ae6..2093428be 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -13,6 +13,7 @@ import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; import DOMEvent from "../../util/DOMEvent"; import getOffset from "../../util/getOffset"; import { HintTooltip } from "./hintTooltip"; +import van from "../dom/van"; // The hint close function used when the user clicks outside the hint let _hintCloseFunction: () => void | undefined; @@ -90,9 +91,9 @@ export async function showHintDialog(hint: Hint, stepId: number) { if (!hintItem) return; const tooltipLayer = HintTooltip({ - position: hintItem.position, + position: van.state(hintItem.position), text: item.hint || "", - targetOffset: getOffset(hintItem.element as HTMLElement), + targetOffset: van.state(getOffset(hintItem.element as HTMLElement)), // hints don't have step numbers showStepNumbers: false, diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 073c62205..28be187ce 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -4,7 +4,6 @@ import van, { ChildDom, State } from "../dom/van"; import { arrowClassName, tooltipClassName, - tooltipTextClassName, } from "../tour/classNames"; import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; @@ -301,8 +300,8 @@ const alignTooltip = ( }; export type TooltipProps = { - position: TooltipPosition; - targetOffset: Offset; + position: State; + targetOffset: State; hintMode: boolean; showStepNumbers: boolean; @@ -313,7 +312,7 @@ export type TooltipProps = { export const Tooltip = ( { - position: initialPosition, + position, targetOffset, hintMode = false, showStepNumbers = false, @@ -324,7 +323,6 @@ export const Tooltip = ( }: TooltipProps, children?: ChildDom[] ) => { - const position = van.state(initialPosition); const top = van.state("auto"); const right = van.state("auto"); const bottom = van.state("auto"); @@ -337,7 +335,7 @@ export const Tooltip = ( const tooltipWidth = van.state(300); const windowSize = getWindowSize(); const tooltipBottomOverflow = van.derive( - () => targetOffset.top + tooltipHeight.val! > windowSize.height + () => targetOffset.val!.top + tooltipHeight.val! > windowSize.height ); van.derive(() => { @@ -350,7 +348,7 @@ export const Tooltip = ( ) { position.val = determineAutoPosition( positionPrecedence, - targetOffset, + targetOffset.val!, tooltipWidth.val, tooltipHeight.val, position.val @@ -367,7 +365,7 @@ export const Tooltip = ( ) { alignTooltip( position.val, - targetOffset, + targetOffset.val!, tooltipWidth.val, tooltipHeight.val, top, diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index b3ab4b32c..b0283f576 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -41,6 +41,9 @@ import { } from "../../util/queryElement"; import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; +import { TourTooltip } from "./tourTooltip"; +import getOffset from "../..//util/getOffset"; +import van from "../dom/van"; /** * Gets the current progress percentage @@ -263,9 +266,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { ); let highlightClass = helperLayerClassName; - let nextTooltipButton: HTMLElement; - let prevTooltipButton: HTMLElement; - let skipTooltipButton: HTMLElement; //check for a current step highlight class if (typeof step.highlightClass === "string") { @@ -282,34 +282,11 @@ export default async function _showElement(tour: Tour, step: TourStep) { tooltipTextClassName, oldReferenceLayer ); - const oldTooltipTitleLayer = getElementByClassName( - tooltipTitleClassName, - oldReferenceLayer - ); - const oldArrowLayer = getElementByClassName( - arrowClassName, - oldReferenceLayer - ); const oldTooltipContainer = getElementByClassName( tooltipClassName, oldReferenceLayer ); - skipTooltipButton = getElementByClassName( - skipButtonClassName, - oldReferenceLayer - ); - - prevTooltipButton = getElementByClassName( - previousButtonClassName, - oldReferenceLayer - ); - - nextTooltipButton = getElementByClassName( - nextButtonClassName, - oldReferenceLayer - ); - //update or reset the helper highlight class setClass(oldHelperLayer, highlightClass); @@ -346,62 +323,10 @@ export default async function _showElement(tour: Tour, step: TourStep) { window.clearTimeout(_lastShowElementTimer); } - const oldHelperNumberLayer = queryElementByClassName( - helperNumberLayerClassName, - oldReferenceLayer - ); - _lastShowElementTimer = window.setTimeout(() => { - // set current step to the label - if (oldHelperNumberLayer !== null) { - oldHelperNumberLayer.innerHTML = `${step.step} ${tour.getOption( - "stepNumbersOfLabel" - )} ${tour.getSteps().length}`; - } - - // set current tooltip text - oldTooltipLayer.innerHTML = step.intro || ""; - - // set current tooltip title - oldTooltipTitleLayer.innerHTML = step.title || ""; - - //set the tooltip position - oldTooltipContainer.style.display = "block"; - placeTooltip( - oldTooltipContainer, - oldArrowLayer, - step.element as HTMLElement, - step.position, - tour.getOption("positionPrecedence"), - tour.getOption("showStepNumbers"), - tour.getOption("autoPosition"), - step.tooltipClass ?? tour.getOption("tooltipClass") - ); - - //change active bullet - _updateBullets(tour.getOption("showBullets"), oldReferenceLayer, step); - - _updateProgressBar( - oldReferenceLayer, - tour.getCurrentStep(), - tour.getSteps().length - ); - //show the tooltip oldTooltipContainer.style.opacity = "1"; - //reset button focus - if ( - nextTooltipButton && - new RegExp(doneButtonClassName, "gi").test(nextTooltipButton.className) - ) { - // skip button is now "done" button - nextTooltipButton.focus(); - } else if (nextTooltipButton) { - //still in the tour, focus on next - nextTooltipButton.focus(); - } - // change the scroll of the window, if needed scrollTo( tour.getOption("scrollToElement"), @@ -420,23 +345,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { const referenceLayer = createElement("div", { className: tooltipReferenceLayerClassName, }); - const arrowLayer = createElement("div", { - className: arrowClassName, - }); - const tooltipLayer = createElement("div", { - className: tooltipClassName, - }); - const tooltipTextLayer = createElement("div", { - className: tooltipTextClassName, - }); - const tooltipHeaderLayer = createElement("div", { - className: tooltipHeaderClassName, - }); - const tooltipTitleLayer = createElement("h1", { - className: tooltipTitleClassName, - }); - - const buttonsLayer = createElement("div"); setStyle(helperLayer, { // the inner box shadow is the border for the highlighted element @@ -471,136 +379,75 @@ export default async function _showElement(tour: Tour, step: TourStep) { appendChild(tour.getTargetElement(), helperLayer, true); appendChild(tour.getTargetElement(), referenceLayer); - tooltipTextLayer.innerHTML = step.intro; - tooltipTitleLayer.innerHTML = step.title; - - setClass(buttonsLayer, tooltipButtonsClassName); - - if (tour.getOption("showButtons") === false) { - buttonsLayer.style.display = "none"; - } - - tooltipHeaderLayer.appendChild(tooltipTitleLayer); - tooltipLayer.appendChild(tooltipHeaderLayer); - tooltipLayer.appendChild(tooltipTextLayer); - - // "Do not show again" checkbox - if (tour.getOption("dontShowAgain")) { - const dontShowAgainWrapper = createElement("div", { - className: dontShowAgainClassName, - }); - const dontShowAgainCheckbox = createElement("input", { - type: "checkbox", - id: dontShowAgainClassName, - name: dontShowAgainClassName, - }); - dontShowAgainCheckbox.onchange = (e) => { - tour.setDontShowAgain((e.target).checked); - }; - const dontShowAgainCheckboxLabel = createElement("label", { - htmlFor: dontShowAgainClassName, - }); - dontShowAgainCheckboxLabel.innerText = - tour.getOption("dontShowAgainLabel"); - dontShowAgainWrapper.appendChild(dontShowAgainCheckbox); - dontShowAgainWrapper.appendChild(dontShowAgainCheckboxLabel); - - tooltipLayer.appendChild(dontShowAgainWrapper); - } - - tooltipLayer.appendChild(_createBullets(tour, step)); - tooltipLayer.appendChild(_createProgressBar(tour)); - - // add helper layer number - const helperNumberLayer = createElement("div"); - - if (tour.getOption("showStepNumbers") === true) { - setClass(helperNumberLayer, helperNumberLayerClassName); - - helperNumberLayer.innerHTML = `${step.step} ${tour.getOption( - "stepNumbersOfLabel" - )} ${tour.getSteps().length}`; - tooltipLayer.appendChild(helperNumberLayer); - } - - tooltipLayer.appendChild(arrowLayer); - referenceLayer.appendChild(tooltipLayer); - - //next button - nextTooltipButton = createElement("a"); + const tooltip = TourTooltip({ + positionPrecedence: tour.getOption("positionPrecedence"), + autoPosition: tour.getOption("autoPosition"), + showStepNumbers: tour.getOption("showStepNumbers"), + + steps: tour.getSteps(), + currentStep: tour.currentStepSignal, + + onBulletClick: (stepNumber: number) => { + tour.goToStep(stepNumber); + }, + + bullets: tour.getOption("showBullets"), + + buttons: tour.getOption("showButtons"), + nextLabel: "Next", + onNextClick: async (e: any) => { + if (!tour.isLastStep()) { + await nextStep(tour); + } else if ( + new RegExp(doneButtonClassName, "gi").test( + (e.target as HTMLElement).className + ) + ) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "done"); + + await tour.exit(); + } + }, + prevLabel: tour.getOption("prevLabel"), + onPrevClick: async () => { + if (tour.getCurrentStep() > 0) { + await previousStep(tour); + } + }, + skipLabel: tour.getOption("skipLabel"), + onSkipClick: async () => { + if (tour.isLastStep()) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "skip"); + } - nextTooltipButton.onclick = async () => { - if (!tour.isLastStep()) { - await nextStep(tour); - } else if ( - new RegExp(doneButtonClassName, "gi").test(nextTooltipButton.className) - ) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "done"); + await tour.callback("skip")?.call(tour, tour.getCurrentStep()); await tour.exit(); - } - }; + }, + buttonClass: tour.getOption("buttonClass"), + nextToDone: tour.getOption("nextToDone"), + doneLabel: tour.getOption("doneLabel"), + hideNext: tour.getOption("hideNext"), + hidePrev: tour.getOption("hidePrev"), - setAnchorAsButton(nextTooltipButton); - nextTooltipButton.innerHTML = tour.getOption("nextLabel"); + progress: tour.getOption("showProgress"), + progressBarAdditionalClass: tour.getOption("progressBarAdditionalClass"), - //previous button - prevTooltipButton = createElement("a"); + stepNumbers: tour.getOption("showStepNumbers"), + stepNumbersOfLabel: tour.getOption("stepNumbersOfLabel"), - prevTooltipButton.onclick = async () => { - if (tour.getCurrentStep() > 0) { - await previousStep(tour); - } - }; - - setAnchorAsButton(prevTooltipButton); - prevTooltipButton.innerHTML = tour.getOption("prevLabel"); - - //skip button - skipTooltipButton = createElement("a", { - className: skipButtonClassName, + dontShowAgain: tour.getOption("dontShowAgain"), + onDontShowAgainChange: (e: any) => { + tour.setDontShowAgain((e.target).checked); + }, + dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), }); - setAnchorAsButton(skipTooltipButton); - skipTooltipButton.innerHTML = tour.getOption("skipLabel"); - - skipTooltipButton.onclick = async () => { - if (tour.isLastStep()) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "skip"); - } - - await tour.callback("skip")?.call(tour, tour.getCurrentStep()); - - await tour.exit(); - }; - - tooltipHeaderLayer.appendChild(skipTooltipButton); - - // in order to prevent displaying previous button always - if (tour.getSteps().length > 1) { - buttonsLayer.appendChild(prevTooltipButton); - } - - // we always need the next button because this - // button changes to "Done" in the last step of the tour - buttonsLayer.appendChild(nextTooltipButton); - tooltipLayer.appendChild(buttonsLayer); - - // set proper position - placeTooltip( - tooltipLayer, - arrowLayer, - step.element as HTMLElement, - step.position, - tour.getOption("positionPrecedence"), - tour.getOption("showStepNumbers"), - tour.getOption("autoPosition"), - step.tooltipClass ?? tour.getOption("tooltipClass") - ); + referenceLayer.appendChild(tooltip); // change the scroll of the window, if needed scrollTo( @@ -608,7 +455,7 @@ export default async function _showElement(tour: Tour, step: TourStep) { step.scrollTo, tour.getOption("scrollPadding"), step.element as HTMLElement, - tooltipLayer + tooltip ); //end of new element if-else condition @@ -628,115 +475,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { _disableInteraction(tour, step); } - // when it's the first step of tour - if (tour.getCurrentStep() === 0 && tour.getSteps().length > 1) { - if (nextTooltipButton) { - setClass( - nextTooltipButton, - tour.getOption("buttonClass"), - nextButtonClassName - ); - nextTooltipButton.innerHTML = tour.getOption("nextLabel"); - } - - if (tour.getOption("hidePrev") === true) { - if (prevTooltipButton) { - setClass( - prevTooltipButton, - tour.getOption("buttonClass"), - previousButtonClassName, - hiddenButtonClassName - ); - } - if (nextTooltipButton) { - addClass(nextTooltipButton, fullButtonClassName); - } - } else { - if (prevTooltipButton) { - setClass( - prevTooltipButton, - tour.getOption("buttonClass"), - previousButtonClassName, - disabledButtonClassName - ); - } - } - } else if (tour.isLastStep() || tour.getSteps().length === 1) { - // last step of tour - if (prevTooltipButton) { - setClass( - prevTooltipButton, - tour.getOption("buttonClass"), - previousButtonClassName - ); - } - - if (tour.getOption("hideNext") === true) { - if (nextTooltipButton) { - setClass( - nextTooltipButton, - tour.getOption("buttonClass"), - nextButtonClassName, - hiddenButtonClassName - ); - } - if (prevTooltipButton) { - addClass(prevTooltipButton, fullButtonClassName); - } - } else { - if (nextTooltipButton) { - if (tour.getOption("nextToDone") === true) { - nextTooltipButton.innerHTML = tour.getOption("doneLabel"); - addClass( - nextTooltipButton, - tour.getOption("buttonClass"), - nextButtonClassName, - doneButtonClassName - ); - } else { - setClass( - nextTooltipButton, - tour.getOption("buttonClass"), - nextButtonClassName, - disabledButtonClassName - ); - } - } - } - } else { - // steps between start and end - if (prevTooltipButton) { - setClass( - prevTooltipButton, - tour.getOption("buttonClass"), - previousButtonClassName - ); - } - if (nextTooltipButton) { - setClass( - nextTooltipButton, - tour.getOption("buttonClass"), - nextButtonClassName - ); - nextTooltipButton.innerHTML = tour.getOption("nextLabel"); - } - } - - if (prevTooltipButton) { - prevTooltipButton.setAttribute("role", "button"); - } - if (nextTooltipButton) { - nextTooltipButton.setAttribute("role", "button"); - } - if (skipTooltipButton) { - skipTooltipButton.setAttribute("role", "button"); - } - - //Set focus on "next" button, so that hitting Enter always moves you onto the next step - if (nextTooltipButton) { - nextTooltipButton.focus(); - } - setShowElement(step.element as HTMLElement); await tour.callback("afterChange")?.call(tour, step.element); diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 9ccfc1cfe..b61e9d4da 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -21,6 +21,7 @@ import { getContainerElement } from "../../util/containerElement"; import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; +import van from "../dom/van"; /** * Intro.js Tour class @@ -28,6 +29,7 @@ import onResize from "./onResize"; export class Tour implements Package { private _steps: TourStep[] = []; private _currentStep: number = -1; + public currentStepSignal = van.state(-1); private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; @@ -186,6 +188,7 @@ export class Tour implements Package { this._direction = "backward"; } + this.currentStepSignal.val = step; this._currentStep = step; return this; } @@ -225,6 +228,7 @@ export class Tour implements Package { * Go to the next step of the tour */ async nextStep() { + this.currentStepSignal.val! += 1; await nextStep(this); return this; } diff --git a/src/packages/tour/tourTooltip.ts b/src/packages/tour/tourTooltip.ts index 07dab220e..7eb42efb7 100644 --- a/src/packages/tour/tourTooltip.ts +++ b/src/packages/tour/tourTooltip.ts @@ -1,5 +1,5 @@ import { Tooltip, type TooltipProps } from "../tooltip/tooltip"; -import van from "../dom/van"; +import van, { PropValueOrDerived, State } from "../dom/van"; import { activeClassName, bulletsClassName, @@ -20,6 +20,7 @@ import { } from "./classNames"; import { TourStep } from "./steps"; import { dataStepNumberAttribute } from "./dataAttributes"; +import getOffset from "../../util/getOffset"; const { h1, div, input, label, ul, li, a, p } = van.tags; @@ -45,26 +46,28 @@ const DontShowAgain = ({ const Bullets = ({ steps, - step, + currentStep, onBulletClick, }: { steps: TourStep[]; - step: TourStep; + currentStep: State; onBulletClick: (stepNumber: number) => void; }): HTMLElement => { + + const step = van.derive(() => steps[currentStep.val!]); + return div({ className: bulletsClassName }, [ ul({ role: "tablist" }, [ ...steps.map(({ step: stepNumber }) => { const innerLi = li( { - role: "presentation", - className: `${ - step.step === stepNumber - } ? ${activeClassName} : ""`, + role: "presentation" }, [ a({ role: "tab", + className: () => + `${step.val!.step === stepNumber ? activeClassName : ""}`, onclick: (e: any) => { const stepNumberAttribute = ( e.target as HTMLElement @@ -91,10 +94,10 @@ const ProgressBar = ({ progressBarAdditionalClass }: { steps: TourStep[]; - currentStep: number; + currentStep: State; progressBarAdditionalClass: string; }) => { - const progress = ((currentStep) / steps.length) * 100; + const progress = van.derive(() => ((currentStep.val!) / steps.length) * 100); return div({ className: progressClassName }, [ div({ @@ -103,7 +106,7 @@ const ProgressBar = ({ "aria-valuemin": "0", "aria-valuemax": "100", "aria-valuenow": () => progress.toString(), - style: () => `width:${progress}%;`, + style: `width:${progress}%;`, }), ]); } @@ -125,16 +128,19 @@ const StepNumber = ({ const Button = ({ label, onClick, + disabled, className }: { label: string; onClick: (e: any) => void; - className?: string + disabled?: PropValueOrDerived + className?: PropValueOrDerived }) => { return a( { role: "button", tabIndex: 0, + ariaDisabled: disabled ?? false, onclick: onClick, className: className ?? "", }, @@ -143,65 +149,129 @@ const Button = ({ }; const NextButton = ({ - label, + steps, + currentStep, + + nextLabel, + doneLabel, + + hideNext, + hidePrev, + nextToDone, onClick, - isDisabled, - isFullButton, - isDoneButton, buttonClass }: { - label: string; + steps: TourStep[]; + currentStep: State; + + nextLabel: string; + doneLabel: string; + + hideNext: boolean; + hidePrev: boolean; + nextToDone: boolean; onClick: (e: any) => void; - isFullButton: boolean; - isDisabled: boolean; - // next button can be a done button as well - isDoneButton: boolean; buttonClass: string; }) => { - const classNames = [buttonClass]; + const isFullButton = van.derive( + () => currentStep.val === 0 && steps.length > 1 && hidePrev + ); - if (isDoneButton) { - classNames.push(doneButtonClassName); - } else { - classNames.push(nextButtonClassName); - } + const isLastStep = van.derive( + () => currentStep.val === steps.length - 1 || steps.length === 1 + ); - if (isDisabled) { - classNames.push(disabledButtonClassName); - } + const isDisabled = van.derive(() => { + // when the current step is the last one or there is only one step to show + return ( + isLastStep.val && + !hideNext && + !nextToDone + ); + }); + + const isDoneButton = van.derive(() => { + return ( + isLastStep.val && + !hideNext && + nextToDone + ); + }); + + const nextButton = Button({ + label: isDoneButton.val ? doneLabel : nextLabel, + onClick, + className: () => { + const classNames = [buttonClass, nextButtonClassName]; + + if (isDoneButton.val) { + classNames.push(doneButtonClassName); + } + + if (isDisabled.val) { + classNames.push(disabledButtonClassName); + } + + if (isFullButton.val) { + classNames.push(fullButtonClassName); + } + + return classNames.filter(Boolean).join(" "); + }, + }); - if (isFullButton) { - classNames.push(fullButtonClassName); - } + nextButton.focus() - return Button({ label, onClick, className: classNames.filter(Boolean).join(" ") }); + return nextButton; } const PrevButton = ({ label, + steps, + currentStep, + hidePrev, + hideNext, onClick, - isFullButton, - isDisabled, - buttonClass + buttonClass, }: { label: string; + steps: TourStep[]; + currentStep: State; + hidePrev: boolean; + hideNext: boolean; onClick: (e: any) => void; - isFullButton: boolean; - isDisabled: boolean; buttonClass: string; }) => { - const classNames = [buttonClass, previousButtonClassName]; + const isDisabled = van.derive(() => { + // when the current step is the first one and there are more steps to show + return currentStep.val === 0 && steps.length > 1 && !hidePrev; + }); - if (isFullButton) { + const isFullButton = van.derive(() => { + // when the current step is the last one or there is only one step to show + return ( + (currentStep.val === steps.length - 1 || steps.length === 1) && hideNext + ); + }); + + return Button({ + label, + onClick, + disabled: () => isDisabled.val, + className: () => { + const classNames = [buttonClass, previousButtonClassName]; + if (isFullButton) { classNames.push(fullButtonClassName); - } + } - if (isDisabled) { + if (isDisabled.val) { classNames.push(disabledButtonClassName); - } + } - return Button({ label, onClick, className: classNames.filter(Boolean).join(" ") }); -} + return classNames.filter(Boolean).join(" "); + }, + }); +}; const Buttons = ({ steps, @@ -221,7 +291,7 @@ const Buttons = ({ onPrevClick, }: { steps: TourStep[]; - currentStep: number; + currentStep: State; buttonClass: string; @@ -236,66 +306,43 @@ const Buttons = ({ prevLabel: string; onPrevClick: (e: any) => void; }) => { - const children: ChildNode[] = []; - - let shouldShowPrev = steps.length > 1; - let isPrevButtonDisabled = false; - let isPrevButtonFull = false; - - let shouldShowNext = true; - let shouldRenderNextAsDone = false; - let isNextButtonFull = false; - let isNextButtonDisabled = false; - - // when the current step is the first one and there are more steps to show - if (currentStep === 0 && steps.length > 1) { - if (hidePrev) { - shouldShowPrev = false; - isNextButtonFull = true; - } else { - isPrevButtonDisabled = true - } - } else if (currentStep === steps.length - 1 || steps.length === 1) { - // when the current step is the last one or there is only one step to show - if (hideNext) { - shouldShowNext = false; - isPrevButtonFull = true; - } else { - if (nextToDone) { - shouldRenderNextAsDone = true; - } else { - isNextButtonDisabled = true; - } - } - } - - // in order to prevent always displaying the previous button - if (shouldShowPrev) { - children.push( - PrevButton({ - label: prevLabel, - onClick: onPrevClick, - isDisabled: isPrevButtonDisabled, - isFullButton: isPrevButtonFull, - buttonClass, - }) - ); - } + const isLastStep = van.derive( + () => currentStep.val === steps.length - 1 || steps.length === 1 + ); - if (shouldShowNext) { - children.push( - NextButton({ - label: shouldRenderNextAsDone ? doneLabel : nextLabel, - isDoneButton: shouldRenderNextAsDone, - onClick: onNextClick, - isDisabled: isNextButtonDisabled, - isFullButton: isNextButtonFull, - buttonClass, - }) - ); - } + const isFirstStep = van.derive( + () => currentStep.val === 0 && steps.length > 1 + ); - return div({ className: tooltipButtonsClassName }, children); + return div( + { className: tooltipButtonsClassName }, + () => + isFirstStep.val && hidePrev + ? null + : PrevButton({ + label: prevLabel, + steps, + currentStep, + hidePrev, + hideNext, + onClick: onPrevClick, + buttonClass, + }), + () => + isLastStep.val && hideNext + ? null + : NextButton({ + currentStep, + steps, + doneLabel, + nextLabel, + onClick: onNextClick, + hideNext, + hidePrev, + nextToDone, + buttonClass, + }) + ); }; const Header = ({ @@ -315,13 +362,9 @@ const Header = ({ ]); }; -export type TourTooltipProps = Omit & { - title: string; - text: string; - +export type TourTooltipProps = Omit & { steps: TourStep[]; - step: TourStep; - currentStep: number; + currentStep: State; bullets: boolean; onBulletClick: (stepNumber: number) => void; @@ -352,14 +395,10 @@ export type TourTooltipProps = Omit & { export const TourTooltip = ({ steps, - step, currentStep, onBulletClick, - title, - text, - bullets, buttons, @@ -388,7 +427,13 @@ export const TourTooltip = ({ }: TourTooltipProps) => { const children = []; - children.push(Header({ title, skipLabel, onSkipClick })); + const step = van.derive(() => steps[currentStep.val!]); + const title = van.derive(() => step.val!.title); + const text = van.derive(() => step.val!.intro); + const position = van.derive(() => step.val!.position); + const targetOffset = van.derive(() => getOffset(step.val!.element as HTMLElement)); + + children.push(Header({ title: title.val!, skipLabel, onSkipClick })); children.push( div({ className: tooltipTextClassName }, p(text)), @@ -399,7 +444,7 @@ export const TourTooltip = ({ } if (bullets) { - children.push(Bullets({ steps, step, onBulletClick })); + children.push(Bullets({ steps, currentStep, onBulletClick })); } if (progress) { @@ -409,7 +454,7 @@ export const TourTooltip = ({ } if (stepNumbers) { - children.push(StepNumber({ step, steps, stepNumbersOfLabel })); + children.push(StepNumber({ step: step.val!, steps, stepNumbersOfLabel })); } if (buttons) { @@ -437,6 +482,8 @@ export const TourTooltip = ({ { ...props, hintMode: false, + position, + targetOffset }, children ); From 4cb8b97119acf440050c98f675659259af1b4702 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 2 Sep 2024 10:12:55 +0100 Subject: [PATCH 14/59] Fix tooltip transitions --- src/packages/hint/hintTooltip.ts | 25 +++++++++------- src/packages/tooltip/tooltip.ts | 51 +++++++++++++++++++++++++++----- src/packages/tour/showElement.ts | 12 +------- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/packages/hint/hintTooltip.ts b/src/packages/hint/hintTooltip.ts index 918758ba6..0f83426dc 100644 --- a/src/packages/hint/hintTooltip.ts +++ b/src/packages/hint/hintTooltip.ts @@ -26,17 +26,20 @@ export const HintTooltip = ({ hintMode: true, }, [ - div({ className: tooltipTextClassName }, p(text)), - closeButtonEnabled - ? a( - { - className: closeButtonClassName, - role: "button", - onclick: closeButtonOnClick, - }, - closeButtonLabel - ) - : null, + div( + { className: tooltipTextClassName }, + p(text), + closeButtonEnabled + ? a( + { + className: closeButtonClassName, + role: "button", + onclick: closeButtonOnClick, + }, + closeButtonLabel + ) + : null + ), ] ); }; diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 28be187ce..1823c9b76 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -329,6 +329,8 @@ export const Tooltip = ( const left = van.state("auto"); const marginLeft = van.state("auto"); const marginTop = van.state("auto"); + const opacity = van.state(0); + const display = van.state("none"); // setting a default height for the tooltip instead of 0 to avoid flickering const tooltipHeight = van.state(150); // max width of the tooltip according to its CSS class @@ -337,7 +339,10 @@ export const Tooltip = ( const tooltipBottomOverflow = van.derive( () => targetOffset.val!.top + tooltipHeight.val! > windowSize.height ); + const isActive = van.state(false); + let isActiveTimeout = 0; + // auto-align tooltip based on position precedence and target offset van.derive(() => { if ( position.val !== "floating" && @@ -356,6 +361,7 @@ export const Tooltip = ( } }); + // align tooltip based on position and target offset van.derive(() => { if ( tooltipWidth.val !== undefined && @@ -381,10 +387,26 @@ export const Tooltip = ( } }); + // show/hide tooltip + van.derive(() => { + if (isActive.val) { + display.val = "block"; + + // wait for the tooltip to be rendered before setting opacity + setTimeout(() => { + opacity.val = 1; + }, 1); + } else { + // hide the tooltip by setting display to none then opacity to 0 to avoid flickering and enable transitions + display.val = "none"; + opacity.val = 0; + } + }); + const tooltip = div( { style: () => - `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};`, + `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val}; opacity: ${opacity.val}; display: ${display.val};`, className: () => `${tooltipClassName} introjs-${position.val}`, role: "dialog", }, @@ -397,14 +419,27 @@ export const Tooltip = ( ] ); - // wait for the tooltip to be rendered before calculating the position - setTimeout(() => { - if (tooltip) { - const tooltipOffset = getOffset(tooltip); - tooltipHeight.val = tooltipOffset.height; - tooltipWidth.val = tooltipOffset.width; + // recalculate tooltip width/height when targetOffset changes + // a targetOffset change means the tooltip width/height needs to be recalculated + van.derive(() => { + if (tooltip && targetOffset.val) { + isActive.val = false; + + // wait for the tooltip to be rendered before calculating the position + setTimeout(() => { + const tooltipOffset = getOffset(tooltip); + tooltipHeight.val = tooltipOffset.height; + tooltipWidth.val = tooltipOffset.width; + }, 1); + + setTimeout(() => { + isActive.val = true; + + // reset the timeout to a higher value to avoid flickering + isActiveTimeout = 350; + }, isActiveTimeout); } - }, 1); + }); return tooltip; }; diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index b0283f576..bbbd156d1 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -282,18 +282,11 @@ export default async function _showElement(tour: Tour, step: TourStep) { tooltipTextClassName, oldReferenceLayer ); - const oldTooltipContainer = getElementByClassName( - tooltipClassName, - oldReferenceLayer - ); + //update or reset the helper highlight class setClass(oldHelperLayer, highlightClass); - //hide the tooltip - oldTooltipContainer.style.opacity = "0"; - oldTooltipContainer.style.display = "none"; - // if the target element is within a scrollable element scrollParentToElement( tour.getOption("scrollToElement"), @@ -324,9 +317,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { } _lastShowElementTimer = window.setTimeout(() => { - //show the tooltip - oldTooltipContainer.style.opacity = "1"; - // change the scroll of the window, if needed scrollTo( tour.getOption("scrollToElement"), From 4f6e29803f0d23af5bb3e5cfc8fa33db22339142 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 2 Sep 2024 10:17:12 +0100 Subject: [PATCH 15/59] remove unused code --- src/packages/tooltip/tooltipPosition.ts | 2 +- src/packages/tour/refresh.ts | 4 +- src/packages/tour/showElement.ts | 212 ------------------------ 3 files changed, 2 insertions(+), 216 deletions(-) diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts index db4cbb89f..be6d7741d 100644 --- a/src/packages/tooltip/tooltipPosition.ts +++ b/src/packages/tooltip/tooltipPosition.ts @@ -1,6 +1,6 @@ import getWindowSize from "../../util/getWindowSize"; import removeEntry from "../../util/removeEntry"; -import getOffset, { Offset } from "../../util/getOffset"; +import { Offset } from "../../util/getOffset"; export type TooltipPosition = | "floating" diff --git a/src/packages/tour/refresh.ts b/src/packages/tour/refresh.ts index ead7ce800..2181c17fc 100644 --- a/src/packages/tour/refresh.ts +++ b/src/packages/tour/refresh.ts @@ -1,5 +1,4 @@ import { placeTooltip } from "../../packages/tooltip"; -import { _recreateBullets, _updateProgressBar } from "./showElement"; import { Tour } from "./tour"; import { getElementByClassName, @@ -60,8 +59,7 @@ export default function refresh(tour: Tour, refreshSteps?: boolean) { if (refreshSteps) { tour.setSteps(fetchSteps(tour)); - _recreateBullets(tour, step); - _updateProgressBar(referenceLayer, currentStep, tour.getSteps().length); + // TODO: how to refresh the tooltip here? do we need to convert the steps into a state? } // re-align tooltip diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index bbbd156d1..d5feabade 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -1,233 +1,26 @@ import scrollParentToElement from "../../util/scrollParentToElement"; import scrollTo from "../../util/scrollTo"; import { addClass, setClass } from "../../util/className"; -import setAnchorAsButton from "../../util/setAnchorAsButton"; import { TourStep, nextStep, previousStep } from "./steps"; -import { placeTooltip } from "../../packages/tooltip"; import removeShowElement from "./removeShowElement"; import createElement from "../../util/createElement"; import setStyle from "../../util/setStyle"; import appendChild from "../../util/appendChild"; import { - activeClassName, - arrowClassName, - bulletsClassName, - disabledButtonClassName, disableInteractionClassName, doneButtonClassName, - dontShowAgainClassName, - fullButtonClassName, helperLayerClassName, - helperNumberLayerClassName, - hiddenButtonClassName, - nextButtonClassName, - previousButtonClassName, - progressBarClassName, - progressClassName, - skipButtonClassName, - tooltipButtonsClassName, - tooltipClassName, - tooltipHeaderClassName, tooltipReferenceLayerClassName, tooltipTextClassName, - tooltipTitleClassName, } from "./classNames"; import { Tour } from "./tour"; -import { dataStepNumberAttribute } from "./dataAttributes"; import { getElementByClassName, - queryElement, queryElementByClassName, } from "../../util/queryElement"; import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; import { TourTooltip } from "./tourTooltip"; -import getOffset from "../..//util/getOffset"; -import van from "../dom/van"; - -/** - * Gets the current progress percentage - * - * @api private - * @returns current progress percentage - */ -export const _getProgress = (currentStep: number, introItemsLength: number) => { - // Steps are 0 indexed - return ((currentStep + 1) / introItemsLength) * 100; -}; - -/** - * Add disableinteraction layer and adjust the size and position of the layer - * - * @api private - */ -export const _disableInteraction = (tour: Tour, step: TourStep) => { - let disableInteractionLayer = queryElementByClassName( - disableInteractionClassName - ); - - if (disableInteractionLayer === null) { - disableInteractionLayer = createElement("div", { - className: disableInteractionClassName, - }); - - tour.getTargetElement().appendChild(disableInteractionLayer); - } - - setPositionRelativeToStep( - tour.getTargetElement(), - disableInteractionLayer, - step, - tour.getOption("helperElementPadding") - ); -}; - -/** - * Creates the bullets layer - * @private - */ -function _createBullets(tour: Tour, step: TourStep): HTMLElement { - const bulletsLayer = createElement("div", { - className: bulletsClassName, - }); - - if (tour.getOption("showBullets") === false) { - bulletsLayer.style.display = "none"; - } - - const ulContainer = createElement("ul"); - ulContainer.setAttribute("role", "tablist"); - - const anchorClick = function (this: HTMLElement) { - const stepNumber = this.getAttribute(dataStepNumberAttribute); - if (stepNumber == null) return; - - tour.goToStep(parseInt(stepNumber, 10)); - }; - - const steps = tour.getSteps(); - for (let i = 0; i < steps.length; i++) { - const { step: stepNumber } = steps[i]; - - const innerLi = createElement("li"); - const anchorLink = createElement("a"); - - innerLi.setAttribute("role", "presentation"); - anchorLink.setAttribute("role", "tab"); - - anchorLink.onclick = anchorClick; - - if (i === step.step - 1) { - setClass(anchorLink, activeClassName); - } - - setAnchorAsButton(anchorLink); - anchorLink.innerHTML = " "; - anchorLink.setAttribute(dataStepNumberAttribute, stepNumber.toString()); - - innerLi.appendChild(anchorLink); - ulContainer.appendChild(innerLi); - } - - bulletsLayer.appendChild(ulContainer); - - return bulletsLayer; -} - -/** - * Deletes and recreates the bullets layer - * @private - */ -export function _recreateBullets(tour: Tour, step: TourStep) { - if (tour.getOption("showBullets")) { - const existing = queryElementByClassName(bulletsClassName); - - if (existing && existing.parentNode) { - existing.parentNode.replaceChild(_createBullets(tour, step), existing); - } - } -} - -/** - * Updates the bullets - */ -function _updateBullets( - showBullets: boolean, - oldReferenceLayer: HTMLElement, - step: TourStep -) { - if (showBullets) { - const oldRefActiveBullet = queryElement( - `.${bulletsClassName} li > a.${activeClassName}`, - oldReferenceLayer - ); - - const oldRefBulletStepNumber = queryElement( - `.${bulletsClassName} li > a[${dataStepNumberAttribute}="${step.step}"]`, - oldReferenceLayer - ); - - if (oldRefActiveBullet && oldRefBulletStepNumber) { - oldRefActiveBullet.className = ""; - setClass(oldRefBulletStepNumber, activeClassName); - } - } -} - -/** - * Creates the progress-bar layer and elements - * @private - */ -function _createProgressBar(tour: Tour) { - const progressLayer = createElement("div"); - - setClass(progressLayer, progressClassName); - - if (tour.getOption("showProgress") === false) { - progressLayer.style.display = "none"; - } - - const progressBar = createElement("div", { - className: progressBarClassName, - }); - - if (tour.getOption("progressBarAdditionalClass")) { - addClass(progressBar, tour.getOption("progressBarAdditionalClass")); - } - - const progress = _getProgress(tour.getCurrentStep(), tour.getSteps().length); - progressBar.setAttribute("role", "progress"); - progressBar.setAttribute("aria-valuemin", "0"); - progressBar.setAttribute("aria-valuemax", "100"); - progressBar.setAttribute("aria-valuenow", progress.toString()); - progressBar.style.cssText = `width:${progress}%;`; - - progressLayer.appendChild(progressBar); - - return progressLayer; -} - -/** - * Updates an existing progress bar variables - * @private - */ -export function _updateProgressBar( - oldReferenceLayer: HTMLElement, - currentStep: number, - introItemsLength: number -) { - const progressBar = queryElement( - `.${progressClassName} .${progressBarClassName}`, - oldReferenceLayer - ); - - if (!progressBar) return; - - const progress = _getProgress(currentStep, introItemsLength); - - progressBar.style.cssText = `width:${progress}%;`; - progressBar.setAttribute("aria-valuenow", progress.toString()); -} /** * To set the show element @@ -460,11 +253,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { disableInteractionLayer.parentNode.removeChild(disableInteractionLayer); } - //disable interaction - if (step.disableInteraction) { - _disableInteraction(tour, step); - } - setShowElement(step.element as HTMLElement); await tour.callback("afterChange")?.call(tour, step.element); From b4c27c5abf261ee7ea216ffc8283b028865791cc Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 3 Sep 2024 22:13:22 +0100 Subject: [PATCH 16/59] add root --- src/packages/hint/tooltip.ts | 2 +- src/packages/tour/addOverlayLayer.ts | 34 ++--- src/packages/tour/exitIntro.ts | 10 +- src/packages/tour/position.ts | 2 +- src/packages/tour/showElement.ts | 63 ++++++--- src/packages/tour/tour.ts | 16 +++ src/packages/tour/tourTooltip.ts | 121 ++++++++++-------- src/util/appendChild.ts | 2 +- src/util/createElement.ts | 2 +- ...eTo.test.ts => positionRelativeTo.test.ts} | 2 +- ...ionRelativeTo.ts => positionRelativeTo.ts} | 36 ++++-- src/util/removeChild.ts | 2 +- src/util/setStyle.ts | 23 ---- src/util/{setStyle.test.ts => style.test.ts} | 2 +- src/util/style.ts | 31 +++++ 15 files changed, 217 insertions(+), 131 deletions(-) rename src/util/{setPositionRelativeTo.test.ts => positionRelativeTo.test.ts} (97%) rename src/util/{setPositionRelativeTo.ts => positionRelativeTo.ts} (74%) delete mode 100644 src/util/setStyle.ts rename src/util/{setStyle.test.ts => style.test.ts} (97%) create mode 100644 src/util/style.ts diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index 2093428be..b34050cab 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -9,7 +9,7 @@ import { Hint } from "./hint"; import createElement from "../../util/createElement"; import { setClass } from "../../util/className"; import { hideHint } from "./hide"; -import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; +import { setPositionRelativeTo } from "../../util/positionRelativeTo"; import DOMEvent from "../../util/DOMEvent"; import getOffset from "../../util/getOffset"; import { HintTooltip } from "./hintTooltip"; diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts index bebac97a7..c2e9d3303 100644 --- a/src/packages/tour/addOverlayLayer.ts +++ b/src/packages/tour/addOverlayLayer.ts @@ -1,7 +1,9 @@ -import createElement from "../../util/createElement"; -import setStyle from "../../util/setStyle"; +import { style } from "../../util/style"; import { Tour } from "./tour"; import { overlayClassName } from "./classNames"; +import van from "../dom/van"; + +const { div } = van.tags; /** * Add overlay layer to the page @@ -9,25 +11,23 @@ import { overlayClassName } from "./classNames"; * @api private */ export default function addOverlayLayer(tour: Tour) { - const overlayLayer = createElement("div", { - className: overlayClassName, - }); + const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; - setStyle(overlayLayer, { - top: 0, - bottom: 0, - left: 0, - right: 0, - position: "fixed", + const overlayLayer = div({ + className: overlayClassName, + style: style({ + top: 0, + bottom: 0, + left: 0, + right: 0, + position: "fixed", + cursor: exitOnOverlayClick ? "pointer" : "auto", + }), }); - tour.getTargetElement().appendChild(overlayLayer); - - if (tour.getOption("exitOnOverlayClick") === true) { - setStyle(overlayLayer, { - cursor: "pointer", - }); + tour.appendToRoot(overlayLayer); + if (exitOnOverlayClick) { overlayLayer.onclick = async () => { await tour.exit(); }; diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index e4a1aa070..d9f6eb1e2 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -46,11 +46,11 @@ export default async function exitIntro( } } - const referenceLayer = queryElementByClassName( - tooltipReferenceLayerClassName, - targetElement - ); - removeChild(referenceLayer); + //const referenceLayer = queryElementByClassName( + // tooltipReferenceLayerClassName, + // targetElement + //); + //removeChild(referenceLayer); //remove disableInteractionLayer const disableInteractionLayer = queryElementByClassName( diff --git a/src/packages/tour/position.ts b/src/packages/tour/position.ts index 2c1aba139..45f22c231 100644 --- a/src/packages/tour/position.ts +++ b/src/packages/tour/position.ts @@ -1,4 +1,4 @@ -import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; +import { setPositionRelativeTo } from "../../util/positionRelativeTo"; import { TourStep } from "./steps"; /** diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index d5feabade..5b4dadd9f 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -4,7 +4,7 @@ import { addClass, setClass } from "../../util/className"; import { TourStep, nextStep, previousStep } from "./steps"; import removeShowElement from "./removeShowElement"; import createElement from "../../util/createElement"; -import setStyle from "../../util/setStyle"; +import setStyle, { style } from "../../util/style"; import appendChild from "../../util/appendChild"; import { disableInteractionClassName, @@ -21,6 +21,35 @@ import { import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; import { TourTooltip } from "./tourTooltip"; +import van from "../dom/van"; + +const { div } = van.tags; + +/** + * Add disableinteraction layer and adjust the size and position of the layer + * + * @api private + */ +export const _disableInteraction = (tour: Tour, step: TourStep) => { + let disableInteractionLayer = queryElementByClassName( + disableInteractionClassName + ); + + if (disableInteractionLayer === null) { + disableInteractionLayer = createElement("div", { + className: disableInteractionClassName, + }); + + tour.getTargetElement().appendChild(disableInteractionLayer); + } + + setPositionRelativeToStep( + tour.getTargetElement(), + disableInteractionLayer, + step, + tour.getOption("helperElementPadding") + ); + }; /** * To set the show element @@ -76,7 +105,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { oldReferenceLayer ); - //update or reset the helper highlight class setClass(oldHelperLayer, highlightClass); @@ -122,21 +150,20 @@ export default async function _showElement(tour: Tour, step: TourStep) { // end of old element if-else condition } else { - const helperLayer = createElement("div", { + const helperLayer = div({ className: highlightClass, + style: style({ + // the inner box shadow is the border for the highlighted element + // the outer box shadow is the overlay effect + "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${tour + .getOption("overlayOpacity") + .toString()}) 0 0 0 5000px`, + }), }); - const referenceLayer = createElement("div", { + const referenceLayer = div({ className: tooltipReferenceLayerClassName, }); - setStyle(helperLayer, { - // the inner box shadow is the border for the highlighted element - // the outer box shadow is the overlay effect - "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${tour - .getOption("overlayOpacity") - .toString()}) 0 0 0 5000px`, - }); - // target is within a scrollable element scrollParentToElement( tour.getOption("scrollToElement"), @@ -159,8 +186,8 @@ export default async function _showElement(tour: Tour, step: TourStep) { ); //add helper layer to target element - appendChild(tour.getTargetElement(), helperLayer, true); - appendChild(tour.getTargetElement(), referenceLayer); + tour.appendToRoot(helperLayer, true); + tour.appendToRoot(referenceLayer); const tooltip = TourTooltip({ positionPrecedence: tour.getOption("positionPrecedence"), @@ -230,7 +257,8 @@ export default async function _showElement(tour: Tour, step: TourStep) { dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), }); - referenceLayer.appendChild(tooltip); + van.add(referenceLayer, tooltip); + //referenceLayer.appendChild(tooltip); // change the scroll of the window, if needed scrollTo( @@ -255,5 +283,10 @@ export default async function _showElement(tour: Tour, step: TourStep) { setShowElement(step.element as HTMLElement); + //disable interaction + if (step.disableInteraction) { + _disableInteraction(tour, step); + } + await tour.callback("afterChange")?.call(tour, step.element); } diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index b61e9d4da..4b25a0527 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -22,6 +22,7 @@ import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; import van from "../dom/van"; +import appendChild from "src/util/appendChild"; /** * Intro.js Tour class @@ -33,6 +34,7 @@ export class Tour implements Package { private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; + private _root: HTMLElement; private readonly callbacks: { beforeChange?: introBeforeChangeCallback; @@ -375,10 +377,24 @@ export class Tour implements Package { } } + public appendToRoot(element: HTMLElement, animate = false) { + appendChild(this._root, element, animate); + } + + private createRoot() { + if (!this._root) { + const { div } = van.tags; + this._root = div({ className: "introjs" }); + this.getTargetElement().appendChild(this._root); + } + } + /** * Starts the tour and shows the first step */ async start() { + this.createRoot(); + if (await start(this)) { this.enableKeyboardNavigation(); this.enableRefreshOnResize(); diff --git a/src/packages/tour/tourTooltip.ts b/src/packages/tour/tourTooltip.ts index 7eb42efb7..a9a0efac0 100644 --- a/src/packages/tour/tourTooltip.ts +++ b/src/packages/tour/tourTooltip.ts @@ -425,66 +425,77 @@ export const TourTooltip = ({ dontShowAgainLabel, ...props }: TourTooltipProps) => { - const children = []; + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); - const step = van.derive(() => steps[currentStep.val!]); - const title = van.derive(() => step.val!.title); - const text = van.derive(() => step.val!.intro); - const position = van.derive(() => step.val!.position); - const targetOffset = van.derive(() => getOffset(step.val!.element as HTMLElement)); + return () => { + // there is nothing to be shown if the step is not defined + if (!step.val) { + return null; + } + + const children = []; + const title = van.derive(() => step.val!.title); + const text = van.derive(() => step.val!.intro); + const position = van.derive(() => step.val!.position); + const targetOffset = van.derive(() => + getOffset(step.val!.element as HTMLElement) + ); - children.push(Header({ title: title.val!, skipLabel, onSkipClick })); + children.push(Header({ title: title.val!, skipLabel, onSkipClick })); - children.push( - div({ className: tooltipTextClassName }, p(text)), -); + children.push(div({ className: tooltipTextClassName }, p(text))); - if (dontShowAgain) { - children.push(DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange })); - } + if (dontShowAgain) { + children.push( + DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange }) + ); + } - if (bullets) { - children.push(Bullets({ steps, currentStep, onBulletClick })); - } + if (bullets) { + children.push(Bullets({ steps, currentStep, onBulletClick })); + } - if (progress) { - children.push( - ProgressBar({ steps, currentStep, progressBarAdditionalClass }) - ); - } - - if (stepNumbers) { - children.push(StepNumber({ step: step.val!, steps, stepNumbersOfLabel })); - } - - if (buttons) { - children.push( - Buttons({ - steps, - currentStep, - - nextLabel: nextLabel, - onNextClick: onNextClick, - - prevLabel: prevLabel, - onPrevClick: onPrevClick, - - buttonClass, - nextToDone, - doneLabel, - hideNext, - hidePrev, - }) + if (progress) { + children.push( + ProgressBar({ steps, currentStep, progressBarAdditionalClass }) + ); + } + + if (stepNumbers) { + children.push(StepNumber({ step: step.val!, steps, stepNumbersOfLabel })); + } + + if (buttons) { + children.push( + Buttons({ + steps, + currentStep, + + nextLabel: nextLabel, + onNextClick: onNextClick, + + prevLabel: prevLabel, + onPrevClick: onPrevClick, + + buttonClass, + nextToDone, + doneLabel, + hideNext, + hidePrev, + }) + ); + } + + return Tooltip( + { + ...props, + hintMode: false, + position, + targetOffset, + }, + children ); - } - - return Tooltip( - { - ...props, - hintMode: false, - position, - targetOffset - }, - children - ); + }; }; diff --git a/src/util/appendChild.ts b/src/util/appendChild.ts index b09d7b3ca..93c72e8a1 100644 --- a/src/util/appendChild.ts +++ b/src/util/appendChild.ts @@ -1,4 +1,4 @@ -import setStyle from "./setStyle"; +import setStyle from "./style"; /** * Appends `element` to `parentElement` diff --git a/src/util/createElement.ts b/src/util/createElement.ts index b3d3e6bee..f27f662f0 100644 --- a/src/util/createElement.ts +++ b/src/util/createElement.ts @@ -1,4 +1,4 @@ -import setStyle from "./setStyle"; +import setStyle from "./style"; /** * Create a DOM element with various attributes diff --git a/src/util/setPositionRelativeTo.test.ts b/src/util/positionRelativeTo.test.ts similarity index 97% rename from src/util/setPositionRelativeTo.test.ts rename to src/util/positionRelativeTo.test.ts index 2392f6fab..101e0c02a 100644 --- a/src/util/setPositionRelativeTo.test.ts +++ b/src/util/positionRelativeTo.test.ts @@ -1,4 +1,4 @@ -import { setPositionRelativeTo } from "./setPositionRelativeTo"; +import { setPositionRelativeTo } from "./positionRelativeTo"; import createElement from "./createElement"; import { getBoundingClientRectSpy } from "../../tests/jest/helper"; diff --git a/src/util/setPositionRelativeTo.ts b/src/util/positionRelativeTo.ts similarity index 74% rename from src/util/setPositionRelativeTo.ts rename to src/util/positionRelativeTo.ts index 2db5bda06..bea93a94b 100644 --- a/src/util/setPositionRelativeTo.ts +++ b/src/util/positionRelativeTo.ts @@ -1,13 +1,9 @@ import getOffset from "./getOffset"; import isFixed from "./isFixed"; import { removeClass, addClass } from "./className"; -import setStyle from "./setStyle"; +import setStyle from "./style"; -/** - * Sets the position of the element relative to the target element - * @api private - */ -export const setPositionRelativeTo = ( +export const getPositionRelativeTo = ( relativeElement: HTMLElement, element: HTMLElement, targetElement: HTMLElement, @@ -28,11 +24,33 @@ export const setPositionRelativeTo = ( const position = getOffset(targetElement, relativeElement); - //set new position to helper layer - setStyle(element, { + return { width: `${position.width + padding}px`, height: `${position.height + padding}px`, top: `${position.top - padding / 2}px`, left: `${position.left - padding / 2}px`, - }); + }; +}; + +/** + * Sets the position of the element relative to the target element + * @api private + */ +export const setPositionRelativeTo = ( + relativeElement: HTMLElement, + element: HTMLElement, + targetElement: HTMLElement, + padding: number +) => { + const styles = getPositionRelativeTo( + relativeElement, + element, + targetElement, + padding + ); + + if (!styles) return; + + //set new position to helper layer + setStyle(element, styles); }; diff --git a/src/util/removeChild.ts b/src/util/removeChild.ts index d9355551f..9a2b6232e 100644 --- a/src/util/removeChild.ts +++ b/src/util/removeChild.ts @@ -1,4 +1,4 @@ -import setStyle from "./setStyle"; +import setStyle from "./style"; /** * Removes `element` from `parentElement` diff --git a/src/util/setStyle.ts b/src/util/setStyle.ts deleted file mode 100644 index 985d3a537..000000000 --- a/src/util/setStyle.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Sets the style of an DOM element - */ -export default function setStyle( - element: HTMLElement, - style: string | { [key: string]: string | number } -) { - let cssText = ""; - - if (element.style.cssText) { - cssText += element.style.cssText; - } - - if (typeof style === "string") { - cssText += style; - } else { - for (const rule in style) { - cssText += `${rule}:${style[rule]};`; - } - } - - element.style.cssText = cssText; -} diff --git a/src/util/setStyle.test.ts b/src/util/style.test.ts similarity index 97% rename from src/util/setStyle.test.ts rename to src/util/style.test.ts index f143360d0..479291c59 100644 --- a/src/util/setStyle.test.ts +++ b/src/util/style.test.ts @@ -1,4 +1,4 @@ -import setStyle from "./setStyle"; +import setStyle from "./style"; describe("setStyle", () => { test("should set style when the list is empty", () => { diff --git a/src/util/style.ts b/src/util/style.ts new file mode 100644 index 000000000..68e4b1839 --- /dev/null +++ b/src/util/style.ts @@ -0,0 +1,31 @@ +export const style = (style: { [key: string]: string | number }) => { + let cssText = ""; + + for (const rule in style) { + cssText += `${rule}:${style[rule]};`; + } + + return cssText; +}; + +/** + * Sets the style of an DOM element + */ +export default function setStyle( + element: HTMLElement, + styles: string | { [key: string]: string | number } +) { + let cssText = ""; + + if (element.style.cssText) { + cssText += element.style.cssText; + } + + if (typeof styles === "string") { + cssText += styles; + } else { + cssText += style(styles); + } + + element.style.cssText = cssText; +} From 7e278ecbfa4ee0c6f1783ab02b8641e2e146eb94 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Wed, 4 Sep 2024 08:47:14 +0100 Subject: [PATCH 17/59] helperLayer --- src/packages/tour/addOverlayLayer.ts | 2 +- src/packages/tour/helperLayer.ts | 77 ++++++++++++++++++++++++++++ src/packages/tour/showElement.ts | 28 ++++------ src/packages/tour/tour.ts | 5 +- 4 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 src/packages/tour/helperLayer.ts diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts index c2e9d3303..4e7b5edfb 100644 --- a/src/packages/tour/addOverlayLayer.ts +++ b/src/packages/tour/addOverlayLayer.ts @@ -25,7 +25,7 @@ export default function addOverlayLayer(tour: Tour) { }), }); - tour.appendToRoot(overlayLayer); + van.add(tour.getRoot(), overlayLayer); if (exitOnOverlayClick) { overlayLayer.onclick = async () => { diff --git a/src/packages/tour/helperLayer.ts b/src/packages/tour/helperLayer.ts new file mode 100644 index 000000000..2b35d64f9 --- /dev/null +++ b/src/packages/tour/helperLayer.ts @@ -0,0 +1,77 @@ +import { style } from "../../util/style"; +import van, { State } from "../dom/van"; +import { helperLayerClassName } from "./classNames"; +import { setPositionRelativeToStep } from "./position"; +import { TourStep } from "./steps"; + +const { div } = van.tags; + +const getClassName = ({ + step, + tourHighlightClass, +}: { + step: TourStep; + tourHighlightClass: string; +}) => { + let highlightClass = helperLayerClassName; + + // check for a current step highlight class + if (typeof step.highlightClass === "string") { + highlightClass += ` ${step.highlightClass}`; + } + + // check for options highlight class + if (typeof tourHighlightClass === "string") { + highlightClass += ` ${tourHighlightClass}`; + } + + return highlightClass; +}; + +export type HelperLayerProps = { + currentStep: State; + steps: TourStep[]; + targetElement: HTMLElement; + tourHighlightClass: string; + overlayOpacity: number; + helperLayerPadding: number; +}; + +export const HelperLayer = ({ + currentStep, + steps, + targetElement, + tourHighlightClass, + overlayOpacity, + helperLayerPadding +}: HelperLayerProps) => { + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); + + return () => { + if (!step.val) { + return null; + } + + const className = getClassName({ step: step.val, tourHighlightClass }); + + const helperLayer = div({ + className, + style: style({ + // the inner box shadow is the border for the highlighted element + // the outer box shadow is the overlay effect + "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${overlayOpacity.toString()}) 0 0 0 5000px`, + }), + }); + + setPositionRelativeToStep( + targetElement, + helperLayer, + step.val, + helperLayerPadding + ); + + return helperLayer; + }; +}; diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index 5b4dadd9f..a151af524 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -4,8 +4,6 @@ import { addClass, setClass } from "../../util/className"; import { TourStep, nextStep, previousStep } from "./steps"; import removeShowElement from "./removeShowElement"; import createElement from "../../util/createElement"; -import setStyle, { style } from "../../util/style"; -import appendChild from "../../util/appendChild"; import { disableInteractionClassName, doneButtonClassName, @@ -22,6 +20,7 @@ import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; import { TourTooltip } from "./tourTooltip"; import van from "../dom/van"; +import { HelperLayer } from "./helperLayer"; const { div } = van.tags; @@ -150,16 +149,15 @@ export default async function _showElement(tour: Tour, step: TourStep) { // end of old element if-else condition } else { - const helperLayer = div({ - className: highlightClass, - style: style({ - // the inner box shadow is the border for the highlighted element - // the outer box shadow is the overlay effect - "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${tour - .getOption("overlayOpacity") - .toString()}) 0 0 0 5000px`, - }), + const helperLayer = HelperLayer({ + currentStep: tour.currentStepSignal, + steps: tour.getSteps(), + targetElement: tour.getTargetElement(), + tourHighlightClass: tour.getOption("highlightClass"), + overlayOpacity: tour.getOption("overlayOpacity"), + helperLayerPadding: tour.getOption("helperElementPadding"), }); + const referenceLayer = div({ className: tooltipReferenceLayerClassName, }); @@ -172,12 +170,6 @@ export default async function _showElement(tour: Tour, step: TourStep) { //set new position to helper layer const helperLayerPadding = tour.getOption("helperElementPadding"); - setPositionRelativeToStep( - tour.getTargetElement(), - helperLayer, - step, - helperLayerPadding - ); setPositionRelativeToStep( tour.getTargetElement(), referenceLayer, @@ -186,7 +178,7 @@ export default async function _showElement(tour: Tour, step: TourStep) { ); //add helper layer to target element - tour.appendToRoot(helperLayer, true); + van.add(tour.getRoot(), helperLayer); tour.appendToRoot(referenceLayer); const tooltip = TourTooltip({ diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 4b25a0527..7cc77c463 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -22,7 +22,7 @@ import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; import van from "../dom/van"; -import appendChild from "src/util/appendChild"; +import appendChild from "../../util/appendChild"; /** * Intro.js Tour class @@ -377,6 +377,9 @@ export class Tour implements Package { } } + // temporary + public getRoot = () => this._root; + public appendToRoot(element: HTMLElement, animate = false) { appendChild(this._root, element, animate); } From f52d74b77d3012c21dad5c6fe00de86993e9e2cd Mon Sep 17 00:00:00 2001 From: binrysearch Date: Wed, 4 Sep 2024 09:14:01 +0100 Subject: [PATCH 18/59] overlayLayer --- src/packages/tour/addOverlayLayer.ts | 26 +++++--------------- src/packages/tour/overlayLayer.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 src/packages/tour/overlayLayer.ts diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts index 4e7b5edfb..ef12da641 100644 --- a/src/packages/tour/addOverlayLayer.ts +++ b/src/packages/tour/addOverlayLayer.ts @@ -1,9 +1,6 @@ -import { style } from "../../util/style"; import { Tour } from "./tour"; -import { overlayClassName } from "./classNames"; import van from "../dom/van"; - -const { div } = van.tags; +import { OverlayLayer } from "./overlayLayer"; /** * Add overlay layer to the page @@ -13,25 +10,14 @@ const { div } = van.tags; export default function addOverlayLayer(tour: Tour) { const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; - const overlayLayer = div({ - className: overlayClassName, - style: style({ - top: 0, - bottom: 0, - left: 0, - right: 0, - position: "fixed", - cursor: exitOnOverlayClick ? "pointer" : "auto", - }), + const overlayLayer = OverlayLayer({ + exitOnOverlayClick, + onExitTour: async () => { + return tour.exit(); + }, }); van.add(tour.getRoot(), overlayLayer); - if (exitOnOverlayClick) { - overlayLayer.onclick = async () => { - await tour.exit(); - }; - } - return true; } diff --git a/src/packages/tour/overlayLayer.ts b/src/packages/tour/overlayLayer.ts new file mode 100644 index 000000000..bafe1a454 --- /dev/null +++ b/src/packages/tour/overlayLayer.ts @@ -0,0 +1,36 @@ +import { style } from "../../util/style"; +import { overlayClassName } from "./classNames"; +import van from "../dom/van"; +import { Tour } from "./tour"; + +const { div } = van.tags; + +export type OverlayLayerProps = { + exitOnOverlayClick: boolean; + onExitTour: () => Promise; +}; + +export const OverlayLayer = ({ + exitOnOverlayClick, + onExitTour, +}: OverlayLayerProps) => { + const overlayLayer = div({ + className: overlayClassName, + style: style({ + top: 0, + bottom: 0, + left: 0, + right: 0, + position: "fixed", + cursor: exitOnOverlayClick ? "pointer" : "auto", + }), + }); + + if (exitOnOverlayClick) { + overlayLayer.onclick = async () => { + await onExitTour(); + }; + } + + return overlayLayer; +}; From 0643c7d9b478b725adecd206e94aa7a098933361 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Wed, 4 Sep 2024 09:59:41 +0100 Subject: [PATCH 19/59] complete referenceLayer and scroll --- src/packages/tooltip/referenceLayer.ts | 113 +++++++++++++++ src/packages/tour/showElement.ts | 188 ++----------------------- src/packages/tour/tourTooltip.ts | 46 +++++- 3 files changed, 168 insertions(+), 179 deletions(-) create mode 100644 src/packages/tooltip/referenceLayer.ts diff --git a/src/packages/tooltip/referenceLayer.ts b/src/packages/tooltip/referenceLayer.ts new file mode 100644 index 000000000..c5bf138f7 --- /dev/null +++ b/src/packages/tooltip/referenceLayer.ts @@ -0,0 +1,113 @@ +import van from "../dom/van"; +import { Tour } from "../tour"; +import { doneButtonClassName, tooltipReferenceLayerClassName } from "../tour/classNames"; +import { setPositionRelativeToStep } from "../tour/position"; +import { nextStep, previousStep } from "../tour/steps"; +import { TourTooltip } from "../tour/tourTooltip"; + +const { div } = van.tags; + +export type ReferenceLayerProps = { + tour: Tour +}; + +export const ReferenceLayer = ({ + tour +}: ReferenceLayerProps) => { + const currentStep = tour.currentStepSignal; + const steps = tour.getSteps(); + const targetElement = tour.getTargetElement(); + const helperElementPadding = tour.getOption("helperElementPadding"); + + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); + + return () => { + if (!step.val) { + return null; + } + + const referenceLayer = div({ + className: tooltipReferenceLayerClassName, + }, TourTooltip({ + positionPrecedence: tour.getOption("positionPrecedence"), + autoPosition: tour.getOption("autoPosition"), + showStepNumbers: tour.getOption("showStepNumbers"), + + steps: tour.getSteps(), + currentStep: tour.currentStepSignal, + + onBulletClick: (stepNumber: number) => { + tour.goToStep(stepNumber); + }, + + bullets: tour.getOption("showBullets"), + + buttons: tour.getOption("showButtons"), + nextLabel: "Next", + onNextClick: async (e: any) => { + if (!tour.isLastStep()) { + await nextStep(tour); + } else if ( + new RegExp(doneButtonClassName, "gi").test( + (e.target as HTMLElement).className + ) + ) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "done"); + + await tour.exit(); + } + }, + prevLabel: tour.getOption("prevLabel"), + onPrevClick: async () => { + if (tour.getCurrentStep() > 0) { + await previousStep(tour); + } + }, + skipLabel: tour.getOption("skipLabel"), + onSkipClick: async () => { + if (tour.isLastStep()) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "skip"); + } + + await tour.callback("skip")?.call(tour, tour.getCurrentStep()); + + await tour.exit(); + }, + buttonClass: tour.getOption("buttonClass"), + nextToDone: tour.getOption("nextToDone"), + doneLabel: tour.getOption("doneLabel"), + hideNext: tour.getOption("hideNext"), + hidePrev: tour.getOption("hidePrev"), + + progress: tour.getOption("showProgress"), + progressBarAdditionalClass: tour.getOption("progressBarAdditionalClass"), + + stepNumbers: tour.getOption("showStepNumbers"), + stepNumbersOfLabel: tour.getOption("stepNumbersOfLabel"), + + scrollToElement: tour.getOption("scrollToElement"), + scrollPadding: tour.getOption("scrollPadding"), + + dontShowAgain: tour.getOption("dontShowAgain"), + onDontShowAgainChange: (e: any) => { + tour.setDontShowAgain((e.target).checked); + }, + dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), + })); + + setPositionRelativeToStep( + targetElement, + referenceLayer, + step.val, + helperElementPadding + ); + + return referenceLayer; + }; +}; diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index a151af524..ec33b1fa0 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -1,28 +1,20 @@ -import scrollParentToElement from "../../util/scrollParentToElement"; -import scrollTo from "../../util/scrollTo"; -import { addClass, setClass } from "../../util/className"; -import { TourStep, nextStep, previousStep } from "./steps"; +import { addClass } from "../../util/className"; +import { TourStep } from "./steps"; import removeShowElement from "./removeShowElement"; import createElement from "../../util/createElement"; import { disableInteractionClassName, - doneButtonClassName, helperLayerClassName, - tooltipReferenceLayerClassName, - tooltipTextClassName, } from "./classNames"; import { Tour } from "./tour"; import { - getElementByClassName, queryElementByClassName, } from "../../util/queryElement"; import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; -import { TourTooltip } from "./tourTooltip"; import van from "../dom/van"; import { HelperLayer } from "./helperLayer"; - -const { div } = van.tags; +import { ReferenceLayer } from "../tooltip/referenceLayer"; /** * Add disableinteraction layer and adjust the size and position of the layer @@ -71,8 +63,6 @@ function setShowElement(targetElement: HTMLElement) { } } -let _lastShowElementTimer: number; - /** * Show an element on the page * @@ -82,73 +72,12 @@ export default async function _showElement(tour: Tour, step: TourStep) { tour.callback("change")?.call(tour, step.element); const oldHelperLayer = queryElementByClassName(helperLayerClassName); - const oldReferenceLayer = queryElementByClassName( - tooltipReferenceLayerClassName - ); - - let highlightClass = helperLayerClassName; - - //check for a current step highlight class - if (typeof step.highlightClass === "string") { - highlightClass += ` ${step.highlightClass}`; - } - - //check for options highlight class - if (typeof tour.getOption("highlightClass") === "string") { - highlightClass += ` ${tour.getOption("highlightClass")}`; - } - - if (oldHelperLayer !== null && oldReferenceLayer !== null) { - const oldTooltipLayer = getElementByClassName( - tooltipTextClassName, - oldReferenceLayer - ); - - //update or reset the helper highlight class - setClass(oldHelperLayer, highlightClass); - - // if the target element is within a scrollable element - scrollParentToElement( - tour.getOption("scrollToElement"), - step.element as HTMLElement - ); - - // set new position to helper layer - const helperLayerPadding = tour.getOption("helperElementPadding"); - setPositionRelativeToStep( - tour.getTargetElement(), - oldHelperLayer, - step, - helperLayerPadding - ); - setPositionRelativeToStep( - tour.getTargetElement(), - oldReferenceLayer, - step, - helperLayerPadding - ); - - //remove old classes if the element still exist - removeShowElement(); - - //we should wait until the CSS3 transition is competed (it's 0.3 sec) to prevent incorrect `height` and `width` calculation - if (_lastShowElementTimer) { - window.clearTimeout(_lastShowElementTimer); - } - _lastShowElementTimer = window.setTimeout(() => { - // change the scroll of the window, if needed - scrollTo( - tour.getOption("scrollToElement"), - step.scrollTo, - tour.getOption("scrollPadding"), - step.element as HTMLElement, - oldTooltipLayer - ); - }, 350); + //remove old classes if the element still exist + removeShowElement(); - // end of old element if-else condition - } else { + // TODO: replace with hasStarted() + if (oldHelperLayer === null) { const helperLayer = HelperLayer({ currentStep: tour.currentStepSignal, steps: tour.getSteps(), @@ -158,110 +87,13 @@ export default async function _showElement(tour: Tour, step: TourStep) { helperLayerPadding: tour.getOption("helperElementPadding"), }); - const referenceLayer = div({ - className: tooltipReferenceLayerClassName, + const referenceLayer = ReferenceLayer({ + tour }); - // target is within a scrollable element - scrollParentToElement( - tour.getOption("scrollToElement"), - step.element as HTMLElement - ); - - //set new position to helper layer - const helperLayerPadding = tour.getOption("helperElementPadding"); - setPositionRelativeToStep( - tour.getTargetElement(), - referenceLayer, - step, - helperLayerPadding - ); - //add helper layer to target element van.add(tour.getRoot(), helperLayer); - tour.appendToRoot(referenceLayer); - - const tooltip = TourTooltip({ - positionPrecedence: tour.getOption("positionPrecedence"), - autoPosition: tour.getOption("autoPosition"), - showStepNumbers: tour.getOption("showStepNumbers"), - - steps: tour.getSteps(), - currentStep: tour.currentStepSignal, - - onBulletClick: (stepNumber: number) => { - tour.goToStep(stepNumber); - }, - - bullets: tour.getOption("showBullets"), - - buttons: tour.getOption("showButtons"), - nextLabel: "Next", - onNextClick: async (e: any) => { - if (!tour.isLastStep()) { - await nextStep(tour); - } else if ( - new RegExp(doneButtonClassName, "gi").test( - (e.target as HTMLElement).className - ) - ) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "done"); - - await tour.exit(); - } - }, - prevLabel: tour.getOption("prevLabel"), - onPrevClick: async () => { - if (tour.getCurrentStep() > 0) { - await previousStep(tour); - } - }, - skipLabel: tour.getOption("skipLabel"), - onSkipClick: async () => { - if (tour.isLastStep()) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "skip"); - } - - await tour.callback("skip")?.call(tour, tour.getCurrentStep()); - - await tour.exit(); - }, - buttonClass: tour.getOption("buttonClass"), - nextToDone: tour.getOption("nextToDone"), - doneLabel: tour.getOption("doneLabel"), - hideNext: tour.getOption("hideNext"), - hidePrev: tour.getOption("hidePrev"), - - progress: tour.getOption("showProgress"), - progressBarAdditionalClass: tour.getOption("progressBarAdditionalClass"), - - stepNumbers: tour.getOption("showStepNumbers"), - stepNumbersOfLabel: tour.getOption("stepNumbersOfLabel"), - - dontShowAgain: tour.getOption("dontShowAgain"), - onDontShowAgainChange: (e: any) => { - tour.setDontShowAgain((e.target).checked); - }, - dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), - }); - - van.add(referenceLayer, tooltip); - //referenceLayer.appendChild(tooltip); - - // change the scroll of the window, if needed - scrollTo( - tour.getOption("scrollToElement"), - step.scrollTo, - tour.getOption("scrollPadding"), - step.element as HTMLElement, - tooltip - ); - - //end of new element if-else condition + van.add(tour.getRoot(), referenceLayer); } // removing previous disable interaction layer diff --git a/src/packages/tour/tourTooltip.ts b/src/packages/tour/tourTooltip.ts index a9a0efac0..36dbbb692 100644 --- a/src/packages/tour/tourTooltip.ts +++ b/src/packages/tour/tourTooltip.ts @@ -21,6 +21,8 @@ import { import { TourStep } from "./steps"; import { dataStepNumberAttribute } from "./dataAttributes"; import getOffset from "../../util/getOffset"; +import scrollParentToElement from "../../util/scrollParentToElement"; +import scrollTo from "../../util/scrollTo"; const { h1, div, input, label, ul, li, a, p } = van.tags; @@ -362,6 +364,33 @@ const Header = ({ ]); }; +const scroll = ({ + step, + tooltip, + scrollToElement, + scrollPadding, +}: { + step: TourStep; + tooltip: HTMLElement; + scrollToElement: boolean; + scrollPadding: number; +}) => { + // when target is within a scrollable element + scrollParentToElement( + scrollToElement, + step.element as HTMLElement + ); + + // change the scroll of the window, if needed + scrollTo( + scrollToElement, + step.scrollTo, + scrollPadding, + step.element as HTMLElement, + tooltip + ); +}; + export type TourTooltipProps = Omit & { steps: TourStep[]; currentStep: State; @@ -387,6 +416,9 @@ export type TourTooltipProps = Omit Date: Wed, 4 Sep 2024 10:15:52 +0100 Subject: [PATCH 20/59] disableInteraction --- src/packages/tour/disableInteraction.ts | 43 ++++++++++++++++++++ src/packages/tour/exitIntro.ts | 36 ++++++++--------- src/packages/tour/showElement.ts | 54 +++++++------------------ 3 files changed, 75 insertions(+), 58 deletions(-) create mode 100644 src/packages/tour/disableInteraction.ts diff --git a/src/packages/tour/disableInteraction.ts b/src/packages/tour/disableInteraction.ts new file mode 100644 index 000000000..1b747105b --- /dev/null +++ b/src/packages/tour/disableInteraction.ts @@ -0,0 +1,43 @@ +import van, { State } from "../dom/van"; +import { disableInteractionClassName } from "./classNames"; +import { setPositionRelativeToStep } from "./position"; +import { TourStep } from "./steps"; + +const { div } = van.tags; + +export type HelperLayerProps = { + currentStep: State; + steps: TourStep[]; + targetElement: HTMLElement; + helperElementPadding: number; +}; + +export const DisableInteraction = ({ + currentStep, + steps, + targetElement, + helperElementPadding, +}: HelperLayerProps) => { + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); + + return () => { + if (!step.val) { + return null; + } + + const disableInteraction = div({ + className: disableInteractionClassName, + }); + + setPositionRelativeToStep( + targetElement, + disableInteraction, + step.val, + helperElementPadding + ); + + return disableInteraction; + }; +}; diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index d9f6eb1e2..125f5eb6e 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -36,15 +36,15 @@ export default async function exitIntro( if (!force && continueExit === false) return false; // remove overlay layers from the page - const overlayLayers = Array.from( - queryElementsByClassName(overlayClassName, targetElement) - ); + // const overlayLayers = Array.from( + // queryElementsByClassName(overlayClassName, targetElement) + // ); - if (overlayLayers && overlayLayers.length) { - for (const overlayLayer of overlayLayers) { - removeChild(overlayLayer); - } - } + // if (overlayLayers && overlayLayers.length) { + // for (const overlayLayer of overlayLayers) { + // removeChild(overlayLayer); + // } + // } //const referenceLayer = queryElementByClassName( // tooltipReferenceLayerClassName, @@ -53,11 +53,11 @@ export default async function exitIntro( //removeChild(referenceLayer); //remove disableInteractionLayer - const disableInteractionLayer = queryElementByClassName( - disableInteractionClassName, - targetElement - ); - removeChild(disableInteractionLayer); + // const disableInteractionLayer = queryElementByClassName( + // disableInteractionClassName, + // targetElement + // ); + // removeChild(disableInteractionLayer); //remove intro floating element const floatingElement = queryElementByClassName( @@ -69,11 +69,11 @@ export default async function exitIntro( removeShowElement(); //remove all helper layers - const helperLayer = queryElementByClassName( - helperLayerClassName, - targetElement - ); - await removeAnimatedChild(helperLayer); + // const helperLayer = queryElementByClassName( + // helperLayerClassName, + // targetElement + // ); + // await removeAnimatedChild(helperLayer); //check if any callback is defined await tour.callback("exit")?.call(tour); diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index ec33b1fa0..871da80ee 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -15,32 +15,7 @@ import getPropValue from "../../util/getPropValue"; import van from "../dom/van"; import { HelperLayer } from "./helperLayer"; import { ReferenceLayer } from "../tooltip/referenceLayer"; - -/** - * Add disableinteraction layer and adjust the size and position of the layer - * - * @api private - */ -export const _disableInteraction = (tour: Tour, step: TourStep) => { - let disableInteractionLayer = queryElementByClassName( - disableInteractionClassName - ); - - if (disableInteractionLayer === null) { - disableInteractionLayer = createElement("div", { - className: disableInteractionClassName, - }); - - tour.getTargetElement().appendChild(disableInteractionLayer); - } - - setPositionRelativeToStep( - tour.getTargetElement(), - disableInteractionLayer, - step, - tour.getOption("helperElementPadding") - ); - }; +import { DisableInteraction } from "./disableInteraction"; /** * To set the show element @@ -88,29 +63,28 @@ export default async function _showElement(tour: Tour, step: TourStep) { }); const referenceLayer = ReferenceLayer({ - tour + tour, }); //add helper layer to target element van.add(tour.getRoot(), helperLayer); van.add(tour.getRoot(), referenceLayer); - } - // removing previous disable interaction layer - const disableInteractionLayer = queryElementByClassName( - disableInteractionClassName, - tour.getTargetElement() - ); - if (disableInteractionLayer && disableInteractionLayer.parentNode) { - disableInteractionLayer.parentNode.removeChild(disableInteractionLayer); + // disable interaction + if (step.disableInteraction) { + van.add( + tour.getRoot(), + DisableInteraction({ + currentStep: tour.currentStepSignal, + steps: tour.getSteps(), + targetElement: tour.getTargetElement(), + helperElementPadding: tour.getOption("helperElementPadding"), + }) + ); + } } setShowElement(step.element as HTMLElement); - //disable interaction - if (step.disableInteraction) { - _disableInteraction(tour, step); - } - await tour.callback("afterChange")?.call(tour, step.element); } From 9e73d8e38bed5e290b8fc507179510c65cb3398c Mon Sep 17 00:00:00 2001 From: binrysearch Date: Wed, 4 Sep 2024 22:58:05 +0100 Subject: [PATCH 21/59] add TourRoot --- src/packages/tour/addOverlayLayer.ts | 16 ++--- src/packages/tour/helperLayer.ts | 54 ++++++-------- src/packages/tour/referenceLayer.ts | 36 ++++++++++ src/packages/tour/showElement.ts | 49 ------------- src/packages/tour/start.ts | 12 +--- src/packages/tour/tour.ts | 19 +---- .../referenceLayer.ts => tour/tourRoot.ts} | 71 ++++++++++++------- 7 files changed, 120 insertions(+), 137 deletions(-) create mode 100644 src/packages/tour/referenceLayer.ts rename src/packages/{tooltip/referenceLayer.ts => tour/tourRoot.ts} (62%) diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts index ef12da641..4c20b7f9c 100644 --- a/src/packages/tour/addOverlayLayer.ts +++ b/src/packages/tour/addOverlayLayer.ts @@ -8,16 +8,16 @@ import { OverlayLayer } from "./overlayLayer"; * @api private */ export default function addOverlayLayer(tour: Tour) { - const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; + //const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; - const overlayLayer = OverlayLayer({ - exitOnOverlayClick, - onExitTour: async () => { - return tour.exit(); - }, - }); + //const overlayLayer = OverlayLayer({ + // exitOnOverlayClick, + // onExitTour: async () => { + // return tour.exit(); + // }, + //}); - van.add(tour.getRoot(), overlayLayer); + //van.add(tour.getRoot(), overlayLayer); return true; } diff --git a/src/packages/tour/helperLayer.ts b/src/packages/tour/helperLayer.ts index 2b35d64f9..8f81bd744 100644 --- a/src/packages/tour/helperLayer.ts +++ b/src/packages/tour/helperLayer.ts @@ -1,5 +1,5 @@ import { style } from "../../util/style"; -import van, { State } from "../dom/van"; +import van from "../dom/van"; import { helperLayerClassName } from "./classNames"; import { setPositionRelativeToStep } from "./position"; import { TourStep } from "./steps"; @@ -29,8 +29,7 @@ const getClassName = ({ }; export type HelperLayerProps = { - currentStep: State; - steps: TourStep[]; + step: TourStep; targetElement: HTMLElement; tourHighlightClass: string; overlayOpacity: number; @@ -38,40 +37,33 @@ export type HelperLayerProps = { }; export const HelperLayer = ({ - currentStep, - steps, + step, targetElement, tourHighlightClass, overlayOpacity, - helperLayerPadding + helperLayerPadding, }: HelperLayerProps) => { - const step = van.derive(() => - currentStep.val !== undefined ? steps[currentStep.val] : null - ); - - return () => { - if (!step.val) { - return null; - } + if (!step) { + return null; + } - const className = getClassName({ step: step.val, tourHighlightClass }); + const className = getClassName({ step: step, tourHighlightClass }); - const helperLayer = div({ - className, - style: style({ - // the inner box shadow is the border for the highlighted element - // the outer box shadow is the overlay effect - "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${overlayOpacity.toString()}) 0 0 0 5000px`, - }), - }); + const helperLayer = div({ + className, + style: style({ + // the inner box shadow is the border for the highlighted element + // the outer box shadow is the overlay effect + "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${overlayOpacity.toString()}) 0 0 0 5000px`, + }), + }); - setPositionRelativeToStep( - targetElement, - helperLayer, - step.val, - helperLayerPadding - ); + setPositionRelativeToStep( + targetElement, + helperLayer, + step, + helperLayerPadding + ); - return helperLayer; - }; + return helperLayer; }; diff --git a/src/packages/tour/referenceLayer.ts b/src/packages/tour/referenceLayer.ts new file mode 100644 index 000000000..62b71448e --- /dev/null +++ b/src/packages/tour/referenceLayer.ts @@ -0,0 +1,36 @@ +import van from "../dom/van"; +import { tooltipReferenceLayerClassName } from "./classNames"; +import { setPositionRelativeToStep } from "./position"; +import { TourStep } from "./steps"; +import { TourTooltip, TourTooltipProps } from "./tourTooltip"; + +const { div } = van.tags; + +export type ReferenceLayerProps = TourTooltipProps & { + step: TourStep; + targetElement: HTMLElement; + helperElementPadding: number; +}; + +export const ReferenceLayer = ({ + step, + targetElement, + helperElementPadding, + ...props +}: ReferenceLayerProps) => { + const referenceLayer = div( + { + className: tooltipReferenceLayerClassName, + }, + TourTooltip(props) + ); + + setPositionRelativeToStep( + targetElement, + referenceLayer, + step, + helperElementPadding + ); + + return referenceLayer; +}; diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index 871da80ee..34bcc1326 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -1,22 +1,8 @@ import { addClass } from "../../util/className"; import { TourStep } from "./steps"; import removeShowElement from "./removeShowElement"; -import createElement from "../../util/createElement"; -import { - disableInteractionClassName, - helperLayerClassName, -} from "./classNames"; import { Tour } from "./tour"; -import { - queryElementByClassName, -} from "../../util/queryElement"; -import { setPositionRelativeToStep } from "./position"; import getPropValue from "../../util/getPropValue"; -import van from "../dom/van"; -import { HelperLayer } from "./helperLayer"; -import { ReferenceLayer } from "../tooltip/referenceLayer"; -import { DisableInteraction } from "./disableInteraction"; - /** * To set the show element * This function set a relative (in most cases) position and changes the z-index @@ -46,44 +32,9 @@ function setShowElement(targetElement: HTMLElement) { export default async function _showElement(tour: Tour, step: TourStep) { tour.callback("change")?.call(tour, step.element); - const oldHelperLayer = queryElementByClassName(helperLayerClassName); - //remove old classes if the element still exist removeShowElement(); - // TODO: replace with hasStarted() - if (oldHelperLayer === null) { - const helperLayer = HelperLayer({ - currentStep: tour.currentStepSignal, - steps: tour.getSteps(), - targetElement: tour.getTargetElement(), - tourHighlightClass: tour.getOption("highlightClass"), - overlayOpacity: tour.getOption("overlayOpacity"), - helperLayerPadding: tour.getOption("helperElementPadding"), - }); - - const referenceLayer = ReferenceLayer({ - tour, - }); - - //add helper layer to target element - van.add(tour.getRoot(), helperLayer); - van.add(tour.getRoot(), referenceLayer); - - // disable interaction - if (step.disableInteraction) { - van.add( - tour.getRoot(), - DisableInteraction({ - currentStep: tour.currentStepSignal, - steps: tour.getSteps(), - targetElement: tour.getTargetElement(), - helperElementPadding: tour.getOption("helperElementPadding"), - }) - ); - } - } - setShowElement(step.element as HTMLElement); await tour.callback("afterChange")?.call(tour, step.element); diff --git a/src/packages/tour/start.ts b/src/packages/tour/start.ts index 074d83421..2b130668c 100644 --- a/src/packages/tour/start.ts +++ b/src/packages/tour/start.ts @@ -1,4 +1,3 @@ -import addOverlayLayer from "./addOverlayLayer"; import { nextStep } from "./steps"; import { fetchSteps } from "./steps"; import { Tour } from "./tour"; @@ -30,13 +29,8 @@ export const start = async (tour: Tour): Promise => { tour.setSteps(steps); - //add overlay layer to the page - if (addOverlayLayer(tour)) { - //then, start the show - await nextStep(tour); + //then, start the show + await nextStep(tour); - return true; - } - - return false; + return true; }; diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 7cc77c463..a38f88f73 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -22,7 +22,7 @@ import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; import van from "../dom/van"; -import appendChild from "../../util/appendChild"; +import { TourRoot } from "./tourRoot"; /** * Intro.js Tour class @@ -34,7 +34,6 @@ export class Tour implements Package { private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; - private _root: HTMLElement; private readonly callbacks: { beforeChange?: introBeforeChangeCallback; @@ -377,28 +376,16 @@ export class Tour implements Package { } } - // temporary - public getRoot = () => this._root; - - public appendToRoot(element: HTMLElement, animate = false) { - appendChild(this._root, element, animate); - } - private createRoot() { - if (!this._root) { - const { div } = van.tags; - this._root = div({ className: "introjs" }); - this.getTargetElement().appendChild(this._root); - } + van.add(this.getTargetElement(), TourRoot({ tour: this })); } /** * Starts the tour and shows the first step */ async start() { - this.createRoot(); - if (await start(this)) { + this.createRoot(); this.enableKeyboardNavigation(); this.enableRefreshOnResize(); } diff --git a/src/packages/tooltip/referenceLayer.ts b/src/packages/tour/tourRoot.ts similarity index 62% rename from src/packages/tooltip/referenceLayer.ts rename to src/packages/tour/tourRoot.ts index c5bf138f7..027d3b0a9 100644 --- a/src/packages/tooltip/referenceLayer.ts +++ b/src/packages/tour/tourRoot.ts @@ -1,24 +1,21 @@ import van from "../dom/van"; -import { Tour } from "../tour"; -import { doneButtonClassName, tooltipReferenceLayerClassName } from "../tour/classNames"; -import { setPositionRelativeToStep } from "../tour/position"; -import { nextStep, previousStep } from "../tour/steps"; -import { TourTooltip } from "../tour/tourTooltip"; +import { ReferenceLayer } from "./referenceLayer"; +import { HelperLayer } from "./helperLayer"; +import { Tour } from "./tour"; +import { DisableInteraction } from "./disableInteraction"; +import { OverlayLayer } from "./overlayLayer"; +import { nextStep, previousStep } from "./steps"; +import { doneButtonClassName } from "./classNames"; const { div } = van.tags; -export type ReferenceLayerProps = { - tour: Tour +export type TourRootProps = { + tour: Tour; }; -export const ReferenceLayer = ({ - tour -}: ReferenceLayerProps) => { +export const TourRoot = ({ tour }: TourRootProps) => { const currentStep = tour.currentStepSignal; const steps = tour.getSteps(); - const targetElement = tour.getTargetElement(); - const helperElementPadding = tour.getOption("helperElementPadding"); - const step = van.derive(() => currentStep.val !== undefined ? steps[currentStep.val] : null ); @@ -28,9 +25,27 @@ export const ReferenceLayer = ({ return null; } - const referenceLayer = div({ - className: tooltipReferenceLayerClassName, - }, TourTooltip({ + const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; + const overlayLayer = OverlayLayer({ + exitOnOverlayClick, + onExitTour: async () => { + return tour.exit(); + }, + }); + + const helperLayer = HelperLayer({ + step: step.val, + targetElement: tour.getTargetElement(), + tourHighlightClass: tour.getOption("highlightClass"), + overlayOpacity: tour.getOption("overlayOpacity"), + helperLayerPadding: tour.getOption("helperElementPadding"), + }); + + const referenceLayer = ReferenceLayer({ + step: step.val, + targetElement: tour.getTargetElement(), + helperElementPadding: tour.getOption("helperElementPadding"), + positionPrecedence: tour.getOption("positionPrecedence"), autoPosition: tour.getOption("autoPosition"), showStepNumbers: tour.getOption("showStepNumbers"), @@ -99,15 +114,23 @@ export const ReferenceLayer = ({ tour.setDontShowAgain((e.target).checked); }, dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), - })); - - setPositionRelativeToStep( - targetElement, + }); + + const disableInteraction = step.val.disableInteraction + ? DisableInteraction({ + currentStep: tour.currentStepSignal, + steps: tour.getSteps(), + targetElement: tour.getTargetElement(), + helperElementPadding: tour.getOption("helperElementPadding"), + }) + : null; + + return div( + { className: "introjs-tour" }, + overlayLayer, + helperLayer, referenceLayer, - step.val, - helperElementPadding + disableInteraction ); - - return referenceLayer; }; }; From 4ba62d43103fcb312ecc5a617acbae71dc5470fa Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 08:27:04 +0100 Subject: [PATCH 22/59] fix helperLayer transition --- src/packages/tour/helperLayer.ts | 40 ++--- src/packages/tour/tourRoot.ts | 244 +++++++++++++++++-------------- 2 files changed, 153 insertions(+), 131 deletions(-) diff --git a/src/packages/tour/helperLayer.ts b/src/packages/tour/helperLayer.ts index 8f81bd744..83b398d42 100644 --- a/src/packages/tour/helperLayer.ts +++ b/src/packages/tour/helperLayer.ts @@ -1,5 +1,5 @@ import { style } from "../../util/style"; -import van from "../dom/van"; +import van, { State } from "../dom/van"; import { helperLayerClassName } from "./classNames"; import { setPositionRelativeToStep } from "./position"; import { TourStep } from "./steps"; @@ -10,14 +10,14 @@ const getClassName = ({ step, tourHighlightClass, }: { - step: TourStep; + step: State; tourHighlightClass: string; }) => { let highlightClass = helperLayerClassName; // check for a current step highlight class - if (typeof step.highlightClass === "string") { - highlightClass += ` ${step.highlightClass}`; + if (step.val && typeof step.val.highlightClass === "string") { + highlightClass += ` ${step.val.highlightClass}`; } // check for options highlight class @@ -29,7 +29,8 @@ const getClassName = ({ }; export type HelperLayerProps = { - step: TourStep; + currentStep: State; + steps: TourStep[]; targetElement: HTMLElement; tourHighlightClass: string; overlayOpacity: number; @@ -37,20 +38,19 @@ export type HelperLayerProps = { }; export const HelperLayer = ({ - step, + currentStep, + steps, targetElement, tourHighlightClass, overlayOpacity, helperLayerPadding, }: HelperLayerProps) => { - if (!step) { - return null; - } - - const className = getClassName({ step: step, tourHighlightClass }); + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); const helperLayer = div({ - className, + className: () => getClassName({ step, tourHighlightClass }), style: style({ // the inner box shadow is the border for the highlighted element // the outer box shadow is the overlay effect @@ -58,12 +58,16 @@ export const HelperLayer = ({ }), }); - setPositionRelativeToStep( - targetElement, - helperLayer, - step, - helperLayerPadding - ); + van.derive(() => { + if (!step.val) return; + + setPositionRelativeToStep( + targetElement, + helperLayer, + step.val, + helperLayerPadding + ); + }); return helperLayer; }; diff --git a/src/packages/tour/tourRoot.ts b/src/packages/tour/tourRoot.ts index 027d3b0a9..5820f6283 100644 --- a/src/packages/tour/tourRoot.ts +++ b/src/packages/tour/tourRoot.ts @@ -16,121 +16,139 @@ export type TourRootProps = { export const TourRoot = ({ tour }: TourRootProps) => { const currentStep = tour.currentStepSignal; const steps = tour.getSteps(); - const step = van.derive(() => - currentStep.val !== undefined ? steps[currentStep.val] : null - ); - return () => { - if (!step.val) { - return null; + const helperLayer = HelperLayer({ + currentStep, + steps, + targetElement: tour.getTargetElement(), + tourHighlightClass: tour.getOption("highlightClass"), + overlayOpacity: tour.getOption("overlayOpacity"), + helperLayerPadding: tour.getOption("helperElementPadding"), + }); + + const root = div( + { className: "introjs-tour" }, + // helperLayer should not be re-rendered when the state changes for the transition to work + helperLayer, + () => { + // do not remove this check, it is necessary for this state-binding to work + // and render the entire section every time the state changes + if (currentStep.val === undefined) { + return null; + } + + const step = van.derive(() => + currentStep.val !== undefined ? steps[currentStep.val] : null + ); + + if (!step.val) { + return null; + } + + const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; + const overlayLayer = OverlayLayer({ + exitOnOverlayClick, + onExitTour: async () => { + return tour.exit(); + }, + }); + + const referenceLayer = ReferenceLayer({ + step: step.val, + targetElement: tour.getTargetElement(), + helperElementPadding: tour.getOption("helperElementPadding"), + + positionPrecedence: tour.getOption("positionPrecedence"), + autoPosition: tour.getOption("autoPosition"), + showStepNumbers: tour.getOption("showStepNumbers"), + + steps: tour.getSteps(), + currentStep: tour.currentStepSignal, + + onBulletClick: (stepNumber: number) => { + tour.goToStep(stepNumber); + }, + + bullets: tour.getOption("showBullets"), + + buttons: tour.getOption("showButtons"), + nextLabel: "Next", + onNextClick: async (e: any) => { + if (!tour.isLastStep()) { + await nextStep(tour); + } else if ( + new RegExp(doneButtonClassName, "gi").test( + (e.target as HTMLElement).className + ) + ) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "done"); + + await tour.exit(); + } + }, + prevLabel: tour.getOption("prevLabel"), + onPrevClick: async () => { + if (tour.getCurrentStep() > 0) { + await previousStep(tour); + } + }, + skipLabel: tour.getOption("skipLabel"), + onSkipClick: async () => { + if (tour.isLastStep()) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "skip"); + } + + await tour.callback("skip")?.call(tour, tour.getCurrentStep()); + + await tour.exit(); + }, + buttonClass: tour.getOption("buttonClass"), + nextToDone: tour.getOption("nextToDone"), + doneLabel: tour.getOption("doneLabel"), + hideNext: tour.getOption("hideNext"), + hidePrev: tour.getOption("hidePrev"), + + progress: tour.getOption("showProgress"), + progressBarAdditionalClass: tour.getOption( + "progressBarAdditionalClass" + ), + + stepNumbers: tour.getOption("showStepNumbers"), + stepNumbersOfLabel: tour.getOption("stepNumbersOfLabel"), + + scrollToElement: tour.getOption("scrollToElement"), + scrollPadding: tour.getOption("scrollPadding"), + + dontShowAgain: tour.getOption("dontShowAgain"), + onDontShowAgainChange: (e: any) => { + tour.setDontShowAgain((e.target).checked); + }, + dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), + }); + + const disableInteraction = step.val.disableInteraction + ? DisableInteraction({ + currentStep: tour.currentStepSignal, + steps: tour.getSteps(), + targetElement: tour.getTargetElement(), + helperElementPadding: tour.getOption("helperElementPadding"), + }) + : null; + + return div(overlayLayer, referenceLayer, disableInteraction); } + ); - const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; - const overlayLayer = OverlayLayer({ - exitOnOverlayClick, - onExitTour: async () => { - return tour.exit(); - }, - }); - - const helperLayer = HelperLayer({ - step: step.val, - targetElement: tour.getTargetElement(), - tourHighlightClass: tour.getOption("highlightClass"), - overlayOpacity: tour.getOption("overlayOpacity"), - helperLayerPadding: tour.getOption("helperElementPadding"), - }); - - const referenceLayer = ReferenceLayer({ - step: step.val, - targetElement: tour.getTargetElement(), - helperElementPadding: tour.getOption("helperElementPadding"), - - positionPrecedence: tour.getOption("positionPrecedence"), - autoPosition: tour.getOption("autoPosition"), - showStepNumbers: tour.getOption("showStepNumbers"), - - steps: tour.getSteps(), - currentStep: tour.currentStepSignal, - - onBulletClick: (stepNumber: number) => { - tour.goToStep(stepNumber); - }, - - bullets: tour.getOption("showBullets"), - - buttons: tour.getOption("showButtons"), - nextLabel: "Next", - onNextClick: async (e: any) => { - if (!tour.isLastStep()) { - await nextStep(tour); - } else if ( - new RegExp(doneButtonClassName, "gi").test( - (e.target as HTMLElement).className - ) - ) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "done"); + van.derive(() => { + // to clean up the root element when the tour is done + if (currentStep.val === undefined || currentStep.val < 0) { + root.remove(); + } + }); - await tour.exit(); - } - }, - prevLabel: tour.getOption("prevLabel"), - onPrevClick: async () => { - if (tour.getCurrentStep() > 0) { - await previousStep(tour); - } - }, - skipLabel: tour.getOption("skipLabel"), - onSkipClick: async () => { - if (tour.isLastStep()) { - await tour - .callback("complete") - ?.call(tour, tour.getCurrentStep(), "skip"); - } - - await tour.callback("skip")?.call(tour, tour.getCurrentStep()); - - await tour.exit(); - }, - buttonClass: tour.getOption("buttonClass"), - nextToDone: tour.getOption("nextToDone"), - doneLabel: tour.getOption("doneLabel"), - hideNext: tour.getOption("hideNext"), - hidePrev: tour.getOption("hidePrev"), - - progress: tour.getOption("showProgress"), - progressBarAdditionalClass: tour.getOption("progressBarAdditionalClass"), - - stepNumbers: tour.getOption("showStepNumbers"), - stepNumbersOfLabel: tour.getOption("stepNumbersOfLabel"), - - scrollToElement: tour.getOption("scrollToElement"), - scrollPadding: tour.getOption("scrollPadding"), - - dontShowAgain: tour.getOption("dontShowAgain"), - onDontShowAgainChange: (e: any) => { - tour.setDontShowAgain((e.target).checked); - }, - dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), - }); - - const disableInteraction = step.val.disableInteraction - ? DisableInteraction({ - currentStep: tour.currentStepSignal, - steps: tour.getSteps(), - targetElement: tour.getTargetElement(), - helperElementPadding: tour.getOption("helperElementPadding"), - }) - : null; - - return div( - { className: "introjs-tour" }, - overlayLayer, - helperLayer, - referenceLayer, - disableInteraction - ); - }; + return root; }; From fced29bb56e872d4cab1d059ccce7f71a69e8d74 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 08:47:15 +0100 Subject: [PATCH 23/59] Update currentStep --- src/packages/tour/addOverlayLayer.ts | 23 ---------- .../DisableInteraction.ts} | 10 ++--- .../HelperLayer.ts} | 12 +++--- .../OverlayLayer.ts} | 8 ++-- .../ReferenceLayer.ts} | 10 ++--- .../{tourRoot.ts => components/TourRoot.ts} | 27 ++++++------ .../TourTooltip.ts} | 28 ++++++------- src/packages/tour/tour.ts | 42 +++++++++++-------- 8 files changed, 73 insertions(+), 87 deletions(-) delete mode 100644 src/packages/tour/addOverlayLayer.ts rename src/packages/tour/{disableInteraction.ts => components/DisableInteraction.ts} (74%) rename src/packages/tour/{helperLayer.ts => components/HelperLayer.ts} (84%) rename src/packages/tour/{overlayLayer.ts => components/OverlayLayer.ts} (78%) rename src/packages/tour/{referenceLayer.ts => components/ReferenceLayer.ts} (68%) rename src/packages/tour/{tourRoot.ts => components/TourRoot.ts} (87%) rename src/packages/tour/{tourTooltip.ts => components/TourTooltip.ts} (94%) diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts deleted file mode 100644 index 4c20b7f9c..000000000 --- a/src/packages/tour/addOverlayLayer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Tour } from "./tour"; -import van from "../dom/van"; -import { OverlayLayer } from "./overlayLayer"; - -/** - * Add overlay layer to the page - * - * @api private - */ -export default function addOverlayLayer(tour: Tour) { - //const exitOnOverlayClick = tour.getOption("exitOnOverlayClick") === true; - - //const overlayLayer = OverlayLayer({ - // exitOnOverlayClick, - // onExitTour: async () => { - // return tour.exit(); - // }, - //}); - - //van.add(tour.getRoot(), overlayLayer); - - return true; -} diff --git a/src/packages/tour/disableInteraction.ts b/src/packages/tour/components/DisableInteraction.ts similarity index 74% rename from src/packages/tour/disableInteraction.ts rename to src/packages/tour/components/DisableInteraction.ts index 1b747105b..e70b270f3 100644 --- a/src/packages/tour/disableInteraction.ts +++ b/src/packages/tour/components/DisableInteraction.ts @@ -1,12 +1,12 @@ -import van, { State } from "../dom/van"; -import { disableInteractionClassName } from "./classNames"; -import { setPositionRelativeToStep } from "./position"; -import { TourStep } from "./steps"; +import van, { State } from "../../dom/van"; +import { disableInteractionClassName } from "../classNames"; +import { setPositionRelativeToStep } from "../position"; +import { TourStep } from "../steps"; const { div } = van.tags; export type HelperLayerProps = { - currentStep: State; + currentStep: State; steps: TourStep[]; targetElement: HTMLElement; helperElementPadding: number; diff --git a/src/packages/tour/helperLayer.ts b/src/packages/tour/components/HelperLayer.ts similarity index 84% rename from src/packages/tour/helperLayer.ts rename to src/packages/tour/components/HelperLayer.ts index 83b398d42..05c200626 100644 --- a/src/packages/tour/helperLayer.ts +++ b/src/packages/tour/components/HelperLayer.ts @@ -1,8 +1,8 @@ -import { style } from "../../util/style"; -import van, { State } from "../dom/van"; -import { helperLayerClassName } from "./classNames"; -import { setPositionRelativeToStep } from "./position"; -import { TourStep } from "./steps"; +import { style } from "../../../util/style"; +import van, { State } from "../../dom/van"; +import { helperLayerClassName } from "../classNames"; +import { setPositionRelativeToStep } from "../position"; +import { TourStep } from "../steps"; const { div } = van.tags; @@ -29,7 +29,7 @@ const getClassName = ({ }; export type HelperLayerProps = { - currentStep: State; + currentStep: State; steps: TourStep[]; targetElement: HTMLElement; tourHighlightClass: string; diff --git a/src/packages/tour/overlayLayer.ts b/src/packages/tour/components/OverlayLayer.ts similarity index 78% rename from src/packages/tour/overlayLayer.ts rename to src/packages/tour/components/OverlayLayer.ts index bafe1a454..dfa9bf39e 100644 --- a/src/packages/tour/overlayLayer.ts +++ b/src/packages/tour/components/OverlayLayer.ts @@ -1,7 +1,7 @@ -import { style } from "../../util/style"; -import { overlayClassName } from "./classNames"; -import van from "../dom/van"; -import { Tour } from "./tour"; +import { style } from "../../../util/style"; +import { overlayClassName } from "../classNames"; +import van from "../../dom/van"; +import { Tour } from "../tour"; const { div } = van.tags; diff --git a/src/packages/tour/referenceLayer.ts b/src/packages/tour/components/ReferenceLayer.ts similarity index 68% rename from src/packages/tour/referenceLayer.ts rename to src/packages/tour/components/ReferenceLayer.ts index 62b71448e..b2260e0c0 100644 --- a/src/packages/tour/referenceLayer.ts +++ b/src/packages/tour/components/ReferenceLayer.ts @@ -1,8 +1,8 @@ -import van from "../dom/van"; -import { tooltipReferenceLayerClassName } from "./classNames"; -import { setPositionRelativeToStep } from "./position"; -import { TourStep } from "./steps"; -import { TourTooltip, TourTooltipProps } from "./tourTooltip"; +import van from "../../dom/van"; +import { tooltipReferenceLayerClassName } from "../classNames"; +import { setPositionRelativeToStep } from "../position"; +import { TourStep } from "../steps"; +import { TourTooltip, TourTooltipProps } from "./TourTooltip"; const { div } = van.tags; diff --git a/src/packages/tour/tourRoot.ts b/src/packages/tour/components/TourRoot.ts similarity index 87% rename from src/packages/tour/tourRoot.ts rename to src/packages/tour/components/TourRoot.ts index 5820f6283..8a097a1ca 100644 --- a/src/packages/tour/tourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -1,11 +1,11 @@ -import van from "../dom/van"; -import { ReferenceLayer } from "./referenceLayer"; -import { HelperLayer } from "./helperLayer"; -import { Tour } from "./tour"; -import { DisableInteraction } from "./disableInteraction"; -import { OverlayLayer } from "./overlayLayer"; -import { nextStep, previousStep } from "./steps"; -import { doneButtonClassName } from "./classNames"; +import van from "../../dom/van"; +import { ReferenceLayer } from "./ReferenceLayer"; +import { HelperLayer } from "./HelperLayer"; +import { Tour } from "../tour"; +import { DisableInteraction } from "./DisableInteraction"; +import { OverlayLayer } from "./OverlayLayer"; +import { nextStep, previousStep } from "../steps"; +import { doneButtonClassName } from "../classNames"; const { div } = van.tags; @@ -14,7 +14,7 @@ export type TourRootProps = { }; export const TourRoot = ({ tour }: TourRootProps) => { - const currentStep = tour.currentStepSignal; + const currentStep = tour.getCurrentStepSignal(); const steps = tour.getSteps(); const helperLayer = HelperLayer({ @@ -63,7 +63,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { showStepNumbers: tour.getOption("showStepNumbers"), steps: tour.getSteps(), - currentStep: tour.currentStepSignal, + currentStep, onBulletClick: (stepNumber: number) => { tour.goToStep(stepNumber); @@ -90,7 +90,8 @@ export const TourRoot = ({ tour }: TourRootProps) => { }, prevLabel: tour.getOption("prevLabel"), onPrevClick: async () => { - if (tour.getCurrentStep() > 0) { + const currentStep = tour.getCurrentStep(); + if (currentStep !== undefined && currentStep > 0) { await previousStep(tour); } }, @@ -132,7 +133,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { const disableInteraction = step.val.disableInteraction ? DisableInteraction({ - currentStep: tour.currentStepSignal, + currentStep, steps: tour.getSteps(), targetElement: tour.getTargetElement(), helperElementPadding: tour.getOption("helperElementPadding"), @@ -145,7 +146,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { van.derive(() => { // to clean up the root element when the tour is done - if (currentStep.val === undefined || currentStep.val < 0) { + if (currentStep.val === undefined) { root.remove(); } }); diff --git a/src/packages/tour/tourTooltip.ts b/src/packages/tour/components/TourTooltip.ts similarity index 94% rename from src/packages/tour/tourTooltip.ts rename to src/packages/tour/components/TourTooltip.ts index 36dbbb692..86ab2d404 100644 --- a/src/packages/tour/tourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -1,5 +1,5 @@ -import { Tooltip, type TooltipProps } from "../tooltip/tooltip"; -import van, { PropValueOrDerived, State } from "../dom/van"; +import { Tooltip, type TooltipProps } from "../../tooltip/tooltip"; +import van, { PropValueOrDerived, State } from "../../dom/van"; import { activeClassName, bulletsClassName, @@ -17,12 +17,12 @@ import { tooltipHeaderClassName, tooltipTextClassName, tooltipTitleClassName, -} from "./classNames"; -import { TourStep } from "./steps"; -import { dataStepNumberAttribute } from "./dataAttributes"; -import getOffset from "../../util/getOffset"; -import scrollParentToElement from "../../util/scrollParentToElement"; -import scrollTo from "../../util/scrollTo"; +} from "../classNames"; +import { TourStep } from "../steps"; +import { dataStepNumberAttribute } from "../dataAttributes"; +import getOffset from "../../../util/getOffset"; +import scrollParentToElement from "../../../util/scrollParentToElement"; +import scrollTo from "../../../util/scrollTo"; const { h1, div, input, label, ul, li, a, p } = van.tags; @@ -52,7 +52,7 @@ const Bullets = ({ onBulletClick, }: { steps: TourStep[]; - currentStep: State; + currentStep: State; onBulletClick: (stepNumber: number) => void; }): HTMLElement => { @@ -96,7 +96,7 @@ const ProgressBar = ({ progressBarAdditionalClass }: { steps: TourStep[]; - currentStep: State; + currentStep: State; progressBarAdditionalClass: string; }) => { const progress = van.derive(() => ((currentStep.val!) / steps.length) * 100); @@ -164,7 +164,7 @@ const NextButton = ({ buttonClass }: { steps: TourStep[]; - currentStep: State; + currentStep: State; nextLabel: string; doneLabel: string; @@ -238,7 +238,7 @@ const PrevButton = ({ }: { label: string; steps: TourStep[]; - currentStep: State; + currentStep: State; hidePrev: boolean; hideNext: boolean; onClick: (e: any) => void; @@ -293,7 +293,7 @@ const Buttons = ({ onPrevClick, }: { steps: TourStep[]; - currentStep: State; + currentStep: State; buttonClass: string; @@ -393,7 +393,7 @@ const scroll = ({ export type TourTooltipProps = Omit & { steps: TourStep[]; - currentStep: State; + currentStep: State; bullets: boolean; onBulletClick: (stepNumber: number) => void; diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index a38f88f73..d12472c30 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -22,15 +22,14 @@ import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; import van from "../dom/van"; -import { TourRoot } from "./tourRoot"; +import { TourRoot } from "./components/TourRoot"; /** * Intro.js Tour class */ export class Tour implements Package { private _steps: TourStep[] = []; - private _currentStep: number = -1; - public currentStepSignal = van.state(-1); + private _currentStep = van.state(undefined); private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; @@ -165,17 +164,25 @@ export class Tour implements Package { } /** - * Get the current step of the tour + * Returns the underlying state of the current step + * This is an internal method and should not be used outside of the package. */ - getCurrentStep(): number { + getCurrentStepSignal() { return this._currentStep; } + /** + * Get the current step of the tour + */ + getCurrentStep(): number | undefined { + return this._currentStep.val; + } + /** * @deprecated `currentStep()` is deprecated, please use `getCurrentStep()` instead. */ - currentStep(): number { - return this._currentStep; + currentStep(): number | undefined { + return this._currentStep.val; } /** @@ -183,14 +190,13 @@ export class Tour implements Package { * @param step */ setCurrentStep(step: number): this { - if (step >= this._currentStep) { + if (this._currentStep.val === undefined || step >= this._currentStep.val) { this._direction = "forward"; } else { this._direction = "backward"; } - this.currentStepSignal.val = step; - this._currentStep = step; + this._currentStep.val = step; return this; } @@ -198,10 +204,11 @@ export class Tour implements Package { * Increment the current step of the tour (does not start the tour step, must be called in conjunction with `nextStep`) */ incrementCurrentStep(): this { - if (this.getCurrentStep() === -1) { + const currentStep = this.getCurrentStep(); + if (currentStep === undefined) { this.setCurrentStep(0); } else { - this.setCurrentStep(this.getCurrentStep() + 1); + this.setCurrentStep(currentStep + 1); } return this; @@ -211,8 +218,9 @@ export class Tour implements Package { * Decrement the current step of the tour (does not start the tour step, must be in conjunction with `previousStep`) */ decrementCurrentStep(): this { - if (this.getCurrentStep() > 0) { - this.setCurrentStep(this._currentStep - 1); + const currentStep = this.getCurrentStep(); + if (currentStep !== undefined && currentStep > 0) { + this.setCurrentStep(currentStep - 1); } return this; @@ -229,7 +237,6 @@ export class Tour implements Package { * Go to the next step of the tour */ async nextStep() { - this.currentStepSignal.val! += 1; await nextStep(this); return this; } @@ -246,7 +253,8 @@ export class Tour implements Package { * Check if the current step is the last step */ isEnd(): boolean { - return this.getCurrentStep() >= this._steps.length; + const currentStep = this.getCurrentStep(); + return currentStep !== undefined && currentStep >= this._steps.length; } /** @@ -315,7 +323,7 @@ export class Tour implements Package { * Returns true if the tour has started */ hasStarted(): boolean { - return this.getCurrentStep() > -1; + return this.getCurrentStep() !== undefined; } /** From 067a7c093b6de94d116478423b3fa43985ef4fd5 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 09:51:27 +0100 Subject: [PATCH 24/59] pass Step to downstream components instead of State --- src/packages/tooltip/tooltip.ts | 68 +++--------- .../tour/components/ReferenceLayer.ts | 5 +- src/packages/tour/components/TourRoot.ts | 22 +++- src/packages/tour/components/TourTooltip.ts | 101 +++++++----------- src/packages/tour/exitIntro.ts | 6 +- src/packages/tour/tour.ts | 4 + 6 files changed, 73 insertions(+), 133 deletions(-) diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 1823c9b76..43b16f7d9 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -1,10 +1,7 @@ -import getOffset, { Offset } from "../../util/getOffset"; +import { Offset } from "../../util/getOffset"; import getWindowSize from "../../util/getWindowSize"; import van, { ChildDom, State } from "../dom/van"; -import { - arrowClassName, - tooltipClassName, -} from "../tour/classNames"; +import { arrowClassName, tooltipClassName } from "../tour/classNames"; import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; const { div } = van.tags; @@ -300,8 +297,8 @@ const alignTooltip = ( }; export type TooltipProps = { - position: State; - targetOffset: State; + position: TooltipPosition; + targetOffset: Offset; hintMode: boolean; showStepNumbers: boolean; @@ -312,7 +309,7 @@ export type TooltipProps = { export const Tooltip = ( { - position, + position: initialPosition, targetOffset, hintMode = false, showStepNumbers = false, @@ -329,31 +326,28 @@ export const Tooltip = ( const left = van.state("auto"); const marginLeft = van.state("auto"); const marginTop = van.state("auto"); - const opacity = van.state(0); - const display = van.state("none"); // setting a default height for the tooltip instead of 0 to avoid flickering const tooltipHeight = van.state(150); // max width of the tooltip according to its CSS class const tooltipWidth = van.state(300); + const position = van.state(initialPosition); const windowSize = getWindowSize(); const tooltipBottomOverflow = van.derive( - () => targetOffset.val!.top + tooltipHeight.val! > windowSize.height + () => targetOffset.top + tooltipHeight.val! > windowSize.height ); - const isActive = van.state(false); - let isActiveTimeout = 0; // auto-align tooltip based on position precedence and target offset van.derive(() => { if ( + position.val !== undefined && position.val !== "floating" && autoPosition && tooltipWidth.val && - tooltipHeight.val && - position.val !== undefined + tooltipHeight.val ) { position.val = determineAutoPosition( positionPrecedence, - targetOffset.val!, + targetOffset, tooltipWidth.val, tooltipHeight.val, position.val @@ -371,7 +365,7 @@ export const Tooltip = ( ) { alignTooltip( position.val, - targetOffset.val!, + targetOffset, tooltipWidth.val, tooltipHeight.val, top, @@ -387,26 +381,10 @@ export const Tooltip = ( } }); - // show/hide tooltip - van.derive(() => { - if (isActive.val) { - display.val = "block"; - - // wait for the tooltip to be rendered before setting opacity - setTimeout(() => { - opacity.val = 1; - }, 1); - } else { - // hide the tooltip by setting display to none then opacity to 0 to avoid flickering and enable transitions - display.val = "none"; - opacity.val = 0; - } - }); - const tooltip = div( { style: () => - `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val}; opacity: ${opacity.val}; display: ${display.val};`, + `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};`, className: () => `${tooltipClassName} introjs-${position.val}`, role: "dialog", }, @@ -419,27 +397,5 @@ export const Tooltip = ( ] ); - // recalculate tooltip width/height when targetOffset changes - // a targetOffset change means the tooltip width/height needs to be recalculated - van.derive(() => { - if (tooltip && targetOffset.val) { - isActive.val = false; - - // wait for the tooltip to be rendered before calculating the position - setTimeout(() => { - const tooltipOffset = getOffset(tooltip); - tooltipHeight.val = tooltipOffset.height; - tooltipWidth.val = tooltipOffset.width; - }, 1); - - setTimeout(() => { - isActive.val = true; - - // reset the timeout to a higher value to avoid flickering - isActiveTimeout = 350; - }, isActiveTimeout); - } - }); - return tooltip; }; diff --git a/src/packages/tour/components/ReferenceLayer.ts b/src/packages/tour/components/ReferenceLayer.ts index b2260e0c0..74fee132d 100644 --- a/src/packages/tour/components/ReferenceLayer.ts +++ b/src/packages/tour/components/ReferenceLayer.ts @@ -1,19 +1,16 @@ import van from "../../dom/van"; import { tooltipReferenceLayerClassName } from "../classNames"; import { setPositionRelativeToStep } from "../position"; -import { TourStep } from "../steps"; import { TourTooltip, TourTooltipProps } from "./TourTooltip"; const { div } = van.tags; export type ReferenceLayerProps = TourTooltipProps & { - step: TourStep; targetElement: HTMLElement; helperElementPadding: number; }; export const ReferenceLayer = ({ - step, targetElement, helperElementPadding, ...props @@ -28,7 +25,7 @@ export const ReferenceLayer = ({ setPositionRelativeToStep( targetElement, referenceLayer, - step, + props.step, helperElementPadding ); diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index 8a097a1ca..54744fdf7 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -6,6 +6,7 @@ import { DisableInteraction } from "./DisableInteraction"; import { OverlayLayer } from "./OverlayLayer"; import { nextStep, previousStep } from "../steps"; import { doneButtonClassName } from "../classNames"; +import { style } from "../../../util/style"; const { div } = van.tags; @@ -26,8 +27,14 @@ export const TourRoot = ({ tour }: TourRootProps) => { helperLayerPadding: tour.getOption("helperElementPadding"), }); + const opacity = van.state(0); + const root = div( - { className: "introjs-tour" }, + { + className: "introjs-tour", + style: () => + style({ transition: "all 250ms ease-out", opacity: `${opacity.val}` }), + }, // helperLayer should not be re-rendered when the state changes for the transition to work helperLayer, () => { @@ -63,7 +70,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { showStepNumbers: tour.getOption("showStepNumbers"), steps: tour.getSteps(), - currentStep, + currentStep: currentStep.val, onBulletClick: (stepNumber: number) => { tour.goToStep(stepNumber); @@ -147,9 +154,18 @@ export const TourRoot = ({ tour }: TourRootProps) => { van.derive(() => { // to clean up the root element when the tour is done if (currentStep.val === undefined) { - root.remove(); + opacity.val = 0; + + setTimeout(() => { + root.remove(); + }, 250); } + }); + setTimeout(() => { + opacity.val = 1; + }, 1); + return root; }; diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 86ab2d404..0abac0db9 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -47,17 +47,15 @@ const DontShowAgain = ({ }; const Bullets = ({ + step, steps, - currentStep, onBulletClick, }: { + step: TourStep, steps: TourStep[]; - currentStep: State; onBulletClick: (stepNumber: number) => void; }): HTMLElement => { - const step = van.derive(() => steps[currentStep.val!]); - return div({ className: bulletsClassName }, [ ul({ role: "tablist" }, [ ...steps.map(({ step: stepNumber }) => { @@ -69,7 +67,7 @@ const Bullets = ({ a({ role: "tab", className: () => - `${step.val!.step === stepNumber ? activeClassName : ""}`, + `${step.step === stepNumber ? activeClassName : ""}`, onclick: (e: any) => { const stepNumberAttribute = ( e.target as HTMLElement @@ -96,10 +94,10 @@ const ProgressBar = ({ progressBarAdditionalClass }: { steps: TourStep[]; - currentStep: State; + currentStep: number; progressBarAdditionalClass: string; }) => { - const progress = van.derive(() => ((currentStep.val!) / steps.length) * 100); + const progress = van.derive(() => ((currentStep) / steps.length) * 100); return div({ className: progressClassName }, [ div({ @@ -164,7 +162,7 @@ const NextButton = ({ buttonClass }: { steps: TourStep[]; - currentStep: State; + currentStep: number; nextLabel: string; doneLabel: string; @@ -175,18 +173,13 @@ const NextButton = ({ onClick: (e: any) => void; buttonClass: string; }) => { - const isFullButton = van.derive( - () => currentStep.val === 0 && steps.length > 1 && hidePrev - ); - - const isLastStep = van.derive( - () => currentStep.val === steps.length - 1 || steps.length === 1 - ); + const isFullButton = currentStep === 0 && steps.length > 1 && hidePrev; + const isLastStep = currentStep === steps.length - 1 || steps.length === 1; const isDisabled = van.derive(() => { // when the current step is the last one or there is only one step to show return ( - isLastStep.val && + isLastStep && !hideNext && !nextToDone ); @@ -194,7 +187,7 @@ const NextButton = ({ const isDoneButton = van.derive(() => { return ( - isLastStep.val && + isLastStep && !hideNext && nextToDone ); @@ -214,7 +207,7 @@ const NextButton = ({ classNames.push(disabledButtonClassName); } - if (isFullButton.val) { + if (isFullButton) { classNames.push(fullButtonClassName); } @@ -238,35 +231,29 @@ const PrevButton = ({ }: { label: string; steps: TourStep[]; - currentStep: State; + currentStep: number; hidePrev: boolean; hideNext: boolean; onClick: (e: any) => void; buttonClass: string; }) => { - const isDisabled = van.derive(() => { - // when the current step is the first one and there are more steps to show - return currentStep.val === 0 && steps.length > 1 && !hidePrev; - }); - - const isFullButton = van.derive(() => { - // when the current step is the last one or there is only one step to show - return ( - (currentStep.val === steps.length - 1 || steps.length === 1) && hideNext - ); - }); + // when the current step is the first one and there are more steps to show + const disabled = currentStep === 0 && steps.length > 1 && !hidePrev; + // when the current step is the last one or there is only one step to show + const isFullButton = + (currentStep === steps.length - 1 || steps.length === 1) && hideNext; return Button({ label, onClick, - disabled: () => isDisabled.val, + disabled, className: () => { const classNames = [buttonClass, previousButtonClassName]; if (isFullButton) { classNames.push(fullButtonClassName); } - if (isDisabled.val) { + if (disabled) { classNames.push(disabledButtonClassName); } @@ -293,7 +280,7 @@ const Buttons = ({ onPrevClick, }: { steps: TourStep[]; - currentStep: State; + currentStep: number; buttonClass: string; @@ -308,18 +295,13 @@ const Buttons = ({ prevLabel: string; onPrevClick: (e: any) => void; }) => { - const isLastStep = van.derive( - () => currentStep.val === steps.length - 1 || steps.length === 1 - ); - - const isFirstStep = van.derive( - () => currentStep.val === 0 && steps.length > 1 - ); + const isLastStep = currentStep === steps.length - 1 || steps.length === 1 + const isFirstStep = currentStep === 0 && steps.length > 1 return div( { className: tooltipButtonsClassName }, () => - isFirstStep.val && hidePrev + isFirstStep && hidePrev ? null : PrevButton({ label: prevLabel, @@ -331,7 +313,7 @@ const Buttons = ({ buttonClass, }), () => - isLastStep.val && hideNext + isLastStep && hideNext ? null : NextButton({ currentStep, @@ -392,8 +374,9 @@ const scroll = ({ }; export type TourTooltipProps = Omit & { + step: TourStep; steps: TourStep[]; - currentStep: State; + currentStep: number; bullets: boolean; onBulletClick: (stepNumber: number) => void; @@ -426,8 +409,9 @@ export type TourTooltipProps = Omit { - const step = van.derive(() => - currentStep.val !== undefined ? steps[currentStep.val] : null - ); - - return () => { - // there is nothing to be shown if the step is not defined - if (!step.val) { - return null; - } - const children = []; - const title = van.derive(() => step.val!.title); - const text = van.derive(() => step.val!.intro); - const position = van.derive(() => step.val!.position); - const targetOffset = van.derive(() => - getOffset(step.val!.element as HTMLElement) - ); + const title = step.title; + const text = step.intro; + const position = step.position; + const targetOffset = getOffset(step.element as HTMLElement); - children.push(Header({ title: title.val!, skipLabel, onSkipClick })); + children.push(Header({ title, skipLabel, onSkipClick })); children.push(div({ className: tooltipTextClassName }, p(text))); @@ -489,7 +461,7 @@ export const TourTooltip = ({ } if (bullets) { - children.push(Bullets({ steps, currentStep, onBulletClick })); + children.push(Bullets({ step, steps, onBulletClick })); } if (progress) { @@ -499,7 +471,7 @@ export const TourTooltip = ({ } if (stepNumbers) { - children.push(StepNumber({ step: step.val!, steps, stepNumbersOfLabel })); + children.push(StepNumber({ step, steps, stepNumbersOfLabel })); } if (buttons) { @@ -534,12 +506,11 @@ export const TourTooltip = ({ ); scroll({ - step: step.val!, + step, tooltip, scrollToElement: scrollToElement, scrollPadding: scrollPadding, }); return tooltip; - }; }; diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index 125f5eb6e..4d1968875 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -2,11 +2,7 @@ import removeShowElement from "./removeShowElement"; import { removeChild, removeAnimatedChild } from "../../util/removeChild"; import { Tour } from "./tour"; import { - disableInteractionClassName, floatingElementClassName, - helperLayerClassName, - overlayClassName, - tooltipReferenceLayerClassName, } from "./classNames"; import { queryElementByClassName, @@ -79,7 +75,7 @@ export default async function exitIntro( await tour.callback("exit")?.call(tour); // set the step to default - tour.setCurrentStep(-1); + tour.resetCurrentStep(); return true; } diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index d12472c30..6f94d22b2 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -185,6 +185,10 @@ export class Tour implements Package { return this._currentStep.val; } + resetCurrentStep() { + this._currentStep.val = undefined; + } + /** * Set the current step of the tour and the direction of the tour * @param step From 5171c76997843958c57dff7c2458d0208002b50d Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 23:15:46 +0100 Subject: [PATCH 25/59] fix transition --- src/packages/tooltip/tooltip.ts | 10 +++++++++- src/packages/tour/components/TourRoot.ts | 8 ++++++-- src/packages/tour/components/TourTooltip.ts | 2 +- src/styles/introjs.scss | 4 ++++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 43b16f7d9..481078a4a 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -326,6 +326,7 @@ export const Tooltip = ( const left = van.state("auto"); const marginLeft = van.state("auto"); const marginTop = van.state("auto"); + const opacity = van.state(0); // setting a default height for the tooltip instead of 0 to avoid flickering const tooltipHeight = van.state(150); // max width of the tooltip according to its CSS class @@ -384,7 +385,7 @@ export const Tooltip = ( const tooltip = div( { style: () => - `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};`, + `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};opacity: ${opacity.val}`, className: () => `${tooltipClassName} introjs-${position.val}`, role: "dialog", }, @@ -397,5 +398,12 @@ export const Tooltip = ( ] ); + // wait for the helper layer to be rendered before showing the tooltip + // this is to prevent the tooltip from flickering when the helper layer is transitioning + // the 300ms delay is coming from the helper layer transition duration + setTimeout(() => { + opacity.val = 1; + }, 300); + return tooltip; }; diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index 54744fdf7..f89eebe27 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -33,7 +33,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { { className: "introjs-tour", style: () => - style({ transition: "all 250ms ease-out", opacity: `${opacity.val}` }), + style({ opacity: `${opacity.val}` }), }, // helperLayer should not be re-rendered when the state changes for the transition to work helperLayer, @@ -147,7 +147,11 @@ export const TourRoot = ({ tour }: TourRootProps) => { }) : null; - return div(overlayLayer, referenceLayer, disableInteraction); + return div( + overlayLayer, + referenceLayer, + disableInteraction + ); } ); diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 0abac0db9..102fff137 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -1,5 +1,5 @@ import { Tooltip, type TooltipProps } from "../../tooltip/tooltip"; -import van, { PropValueOrDerived, State } from "../../dom/van"; +import van, { PropValueOrDerived } from "../../dom/van"; import { activeClassName, bulletsClassName, diff --git a/src/styles/introjs.scss b/src/styles/introjs.scss index fcc9f2f0f..087aefe05 100644 --- a/src/styles/introjs.scss +++ b/src/styles/introjs.scss @@ -13,6 +13,10 @@ $font_family: "Helvetica Neue", Inter, ui-sans-serif, "Apple Color Emoji", $background_color_9: #08c; $background_color_10: rgba(136, 136, 136, 0.24); +.introjs-tour { + transition: all 0.3s ease-out; +} + .introjs-overlay { position: absolute; box-sizing: content-box; From 4db5719b278f0a01e09dc135ceed7e7922d45bcf Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 23:29:09 +0100 Subject: [PATCH 26/59] fix the tooltip transition --- src/packages/tooltip/tooltip.ts | 10 ++++++---- src/packages/tour/components/TourRoot.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 481078a4a..a29c0a326 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -302,6 +302,8 @@ export type TooltipProps = { hintMode: boolean; showStepNumbers: boolean; + transitionDuration?: number; + // auto-alignment properties autoPosition: boolean; positionPrecedence: TooltipPosition[]; @@ -314,6 +316,8 @@ export const Tooltip = ( hintMode = false, showStepNumbers = false, + transitionDuration = 0, + // auto-alignment properties autoPosition = true, positionPrecedence = [], @@ -398,12 +402,10 @@ export const Tooltip = ( ] ); - // wait for the helper layer to be rendered before showing the tooltip - // this is to prevent the tooltip from flickering when the helper layer is transitioning - // the 300ms delay is coming from the helper layer transition duration + // apply the transition effect setTimeout(() => { opacity.val = 1; - }, 300); + }, transitionDuration); return tooltip; }; diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index f89eebe27..a74c44463 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -28,6 +28,9 @@ export const TourRoot = ({ tour }: TourRootProps) => { }); const opacity = van.state(0); + // render the tooltip immediately when the tour starts + // but we reset the transition duration to 300ms when the tooltip is rendered for the first time + let tooltipTransitionDuration = 0; const root = div( { @@ -65,6 +68,8 @@ export const TourRoot = ({ tour }: TourRootProps) => { targetElement: tour.getTargetElement(), helperElementPadding: tour.getOption("helperElementPadding"), + transitionDuration: tooltipTransitionDuration, + positionPrecedence: tour.getOption("positionPrecedence"), autoPosition: tour.getOption("autoPosition"), showStepNumbers: tour.getOption("showStepNumbers"), @@ -147,6 +152,11 @@ export const TourRoot = ({ tour }: TourRootProps) => { }) : null; + // wait for the helper layer to be rendered before showing the tooltip + // this is to prevent the tooltip from flickering when the helper layer is transitioning + // the 300ms delay is coming from the helper layer transition duration + tooltipTransitionDuration = 300; + return div( overlayLayer, referenceLayer, From 9d2c558f6d3584aa8962105f551ec1923f663dce Mon Sep 17 00:00:00 2001 From: binrysearch Date: Thu, 5 Sep 2024 23:38:54 +0100 Subject: [PATCH 27/59] fix progress bar and next button focus --- src/packages/tour/components/TourTooltip.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 102fff137..7a486bc76 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -97,7 +97,7 @@ const ProgressBar = ({ currentStep: number; progressBarAdditionalClass: string; }) => { - const progress = van.derive(() => ((currentStep) / steps.length) * 100); + const progress = (currentStep / steps.length) * 100; return div({ className: progressClassName }, [ div({ @@ -215,7 +215,10 @@ const NextButton = ({ }, }); - nextButton.focus() + // wait for the button to be rendered + setTimeout(() => { + nextButton.focus() + }, 1); return nextButton; } From 7400e1b13c9094ba23a2094528ce6a19f9c1debb Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sat, 7 Sep 2024 09:10:39 +0100 Subject: [PATCH 28/59] fix exitIntro tests --- src/packages/tour/components/TourRoot.ts | 10 +- src/packages/tour/components/TourTooltip.ts | 329 ++++++++++---------- src/packages/tour/exitIntro.test.ts | 2 +- src/packages/tour/exitIntro.ts | 47 +-- src/packages/tour/steps.ts | 18 +- 5 files changed, 183 insertions(+), 223 deletions(-) diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index a74c44463..2754b716f 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -35,8 +35,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { const root = div( { className: "introjs-tour", - style: () => - style({ opacity: `${opacity.val}` }), + style: () => style({ opacity: `${opacity.val}` }), }, // helperLayer should not be re-rendered when the state changes for the transition to work helperLayer, @@ -157,11 +156,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { // the 300ms delay is coming from the helper layer transition duration tooltipTransitionDuration = 300; - return div( - overlayLayer, - referenceLayer, - disableInteraction - ); + return div(overlayLayer, referenceLayer, disableInteraction); } ); @@ -174,7 +169,6 @@ export const TourRoot = ({ tour }: TourRootProps) => { root.remove(); }, 250); } - }); setTimeout(() => { diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 7a486bc76..0296540ce 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -51,22 +51,21 @@ const Bullets = ({ steps, onBulletClick, }: { - step: TourStep, + step: TourStep; steps: TourStep[]; onBulletClick: (stepNumber: number) => void; }): HTMLElement => { - return div({ className: bulletsClassName }, [ ul({ role: "tablist" }, [ ...steps.map(({ step: stepNumber }) => { const innerLi = li( { - role: "presentation" + role: "presentation", }, [ a({ role: "tab", - className: () => + className: () => `${step.step === stepNumber ? activeClassName : ""}`, onclick: (e: any) => { const stepNumberAttribute = ( @@ -89,27 +88,29 @@ const Bullets = ({ }; const ProgressBar = ({ - steps, - currentStep, - progressBarAdditionalClass + steps, + currentStep, + progressBarAdditionalClass, }: { - steps: TourStep[]; - currentStep: number; - progressBarAdditionalClass: string; + steps: TourStep[]; + currentStep: number; + progressBarAdditionalClass: string; }) => { - const progress = (currentStep / steps.length) * 100; - - return div({ className: progressClassName }, [ - div({ - className: `${progressBarClassName} ${progressBarAdditionalClass ? progressBarAdditionalClass : ""}`, - "role": "progress", - "aria-valuemin": "0", - "aria-valuemax": "100", - "aria-valuenow": () => progress.toString(), - style: `width:${progress}%;`, - }), - ]); -} + const progress = (currentStep / steps.length) * 100; + + return div({ className: progressClassName }, [ + div({ + className: `${progressBarClassName} ${ + progressBarAdditionalClass ? progressBarAdditionalClass : "" + }`, + role: "progress", + "aria-valuemin": "0", + "aria-valuemax": "100", + "aria-valuenow": () => progress.toString(), + style: `width:${progress}%;`, + }), + ]); +}; const StepNumber = ({ step, @@ -129,12 +130,12 @@ const Button = ({ label, onClick, disabled, - className + className, }: { label: string; onClick: (e: any) => void; - disabled?: PropValueOrDerived - className?: PropValueOrDerived + disabled?: PropValueOrDerived; + className?: PropValueOrDerived; }) => { return a( { @@ -159,7 +160,7 @@ const NextButton = ({ hidePrev, nextToDone, onClick, - buttonClass + buttonClass, }: { steps: TourStep[]; currentStep: number; @@ -173,55 +174,47 @@ const NextButton = ({ onClick: (e: any) => void; buttonClass: string; }) => { - const isFullButton = currentStep === 0 && steps.length > 1 && hidePrev; - const isLastStep = currentStep === steps.length - 1 || steps.length === 1; - - const isDisabled = van.derive(() => { - // when the current step is the last one or there is only one step to show - return ( - isLastStep && - !hideNext && - !nextToDone - ); - }); - - const isDoneButton = van.derive(() => { - return ( - isLastStep && - !hideNext && - nextToDone - ); - }); - - const nextButton = Button({ - label: isDoneButton.val ? doneLabel : nextLabel, - onClick, - className: () => { - const classNames = [buttonClass, nextButtonClassName]; - - if (isDoneButton.val) { - classNames.push(doneButtonClassName); - } - - if (isDisabled.val) { - classNames.push(disabledButtonClassName); - } - - if (isFullButton) { - classNames.push(fullButtonClassName); - } - - return classNames.filter(Boolean).join(" "); - }, - }); + const isFullButton = currentStep === 0 && steps.length > 1 && hidePrev; + const isLastStep = currentStep === steps.length - 1 || steps.length === 1; + + const isDisabled = van.derive(() => { + // when the current step is the last one or there is only one step to show + return isLastStep && !hideNext && !nextToDone; + }); + + const isDoneButton = van.derive(() => { + return isLastStep && !hideNext && nextToDone; + }); + + const nextButton = Button({ + label: isDoneButton.val ? doneLabel : nextLabel, + onClick, + className: () => { + const classNames = [buttonClass, nextButtonClassName]; + + if (isDoneButton.val) { + classNames.push(doneButtonClassName); + } + + if (isDisabled.val) { + classNames.push(disabledButtonClassName); + } + + if (isFullButton) { + classNames.push(fullButtonClassName); + } + + return classNames.filter(Boolean).join(" "); + }, + }); - // wait for the button to be rendered - setTimeout(() => { - nextButton.focus() - }, 1); + // wait for the button to be rendered + setTimeout(() => { + nextButton.focus(); + }, 1); - return nextButton; -} + return nextButton; +}; const PrevButton = ({ label, @@ -298,8 +291,8 @@ const Buttons = ({ prevLabel: string; onPrevClick: (e: any) => void; }) => { - const isLastStep = currentStep === steps.length - 1 || steps.length === 1 - const isFirstStep = currentStep === 0 && steps.length > 1 + const isLastStep = currentStep === steps.length - 1 || steps.length === 1; + const isFirstStep = currentStep === 0 && steps.length > 1; return div( { className: tooltipButtonsClassName }, @@ -345,38 +338,42 @@ const Header = ({ }) => { return div({ className: tooltipHeaderClassName }, [ h1({ className: tooltipTitleClassName }, title), - Button({ className: skipButtonClassName, label: skipLabel, onClick: onSkipClick }), + Button({ + className: skipButtonClassName, + label: skipLabel, + onClick: onSkipClick, + }), ]); }; const scroll = ({ - step, - tooltip, - scrollToElement, - scrollPadding, + step, + tooltip, + scrollToElement, + scrollPadding, }: { - step: TourStep; - tooltip: HTMLElement; - scrollToElement: boolean; - scrollPadding: number; + step: TourStep; + tooltip: HTMLElement; + scrollToElement: boolean; + scrollPadding: number; }) => { - // when target is within a scrollable element - scrollParentToElement( - scrollToElement, - step.element as HTMLElement - ); + // when target is within a scrollable element + scrollParentToElement(scrollToElement, step.element as HTMLElement); - // change the scroll of the window, if needed - scrollTo( - scrollToElement, - step.scrollTo, - scrollPadding, - step.element as HTMLElement, - tooltip - ); + // change the scroll of the window, if needed + scrollTo( + scrollToElement, + step.scrollTo, + scrollPadding, + step.element as HTMLElement, + tooltip + ); }; -export type TourTooltipProps = Omit & { +export type TourTooltipProps = Omit< + TooltipProps, + "hintMode" | "position" | "targetOffset" +> & { step: TourStep; steps: TourStep[]; currentStep: number; @@ -389,7 +386,7 @@ export type TourTooltipProps = Omit void; prevLabel: string; onPrevClick: (e: any) => void; - skipLabel: string, + skipLabel: string; onSkipClick: (e: any) => void; buttonClass: string; nextToDone: boolean; @@ -405,7 +402,7 @@ export type TourTooltipProps = Omit void; @@ -447,73 +444,71 @@ export const TourTooltip = ({ dontShowAgainLabel, ...props }: TourTooltipProps) => { - const children = []; - const title = step.title; - const text = step.intro; - const position = step.position; - const targetOffset = getOffset(step.element as HTMLElement); - - children.push(Header({ title, skipLabel, onSkipClick })); - - children.push(div({ className: tooltipTextClassName }, p(text))); - - if (dontShowAgain) { - children.push( - DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange }) - ); - } - - if (bullets) { - children.push(Bullets({ step, steps, onBulletClick })); - } - - if (progress) { - children.push( - ProgressBar({ steps, currentStep, progressBarAdditionalClass }) - ); - } - - if (stepNumbers) { - children.push(StepNumber({ step, steps, stepNumbersOfLabel })); - } - - if (buttons) { - children.push( - Buttons({ - steps, - currentStep, - - nextLabel: nextLabel, - onNextClick: onNextClick, - - prevLabel: prevLabel, - onPrevClick: onPrevClick, - - buttonClass, - nextToDone, - doneLabel, - hideNext, - hidePrev, - }) - ); - } - - const tooltip = Tooltip( - { - ...props, - hintMode: false, - position, - targetOffset, - }, - children + const children = []; + const title = step.title; + const text = step.intro; + const position = step.position; + const targetOffset = getOffset(step.element as HTMLElement); + + children.push(Header({ title, skipLabel, onSkipClick })); + + children.push(div({ className: tooltipTextClassName }, p(text))); + + if (dontShowAgain) { + children.push(DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange })); + } + + if (bullets) { + children.push(Bullets({ step, steps, onBulletClick })); + } + + if (progress) { + children.push( + ProgressBar({ steps, currentStep, progressBarAdditionalClass }) ); + } + + if (stepNumbers) { + children.push(StepNumber({ step, steps, stepNumbersOfLabel })); + } + + if (buttons) { + children.push( + Buttons({ + steps, + currentStep, + + nextLabel: nextLabel, + onNextClick: onNextClick, + + prevLabel: prevLabel, + onPrevClick: onPrevClick, + + buttonClass, + nextToDone, + doneLabel, + hideNext, + hidePrev, + }) + ); + } + + const tooltip = Tooltip( + { + ...props, + hintMode: false, + position, + targetOffset, + }, + children + ); - scroll({ - step, - tooltip, - scrollToElement: scrollToElement, - scrollPadding: scrollPadding, - }); + scroll({ + step, + tooltip, + scrollToElement: scrollToElement, + scrollPadding: scrollPadding, + }); - return tooltip; + return tooltip; }; diff --git a/src/packages/tour/exitIntro.test.ts b/src/packages/tour/exitIntro.test.ts index ab88b62e5..07b4e3d1c 100644 --- a/src/packages/tour/exitIntro.test.ts +++ b/src/packages/tour/exitIntro.test.ts @@ -8,7 +8,7 @@ describe("exitIntro", () => { await mockTour.exit(false); - expect(mockTour.getCurrentStep()).toBe(-1); + expect(mockTour.getCurrentStep()).toBeUndefined(); }); test("should call the onexit and onbeforeexit callbacks", async () => { diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index 4d1968875..de0e5ff17 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -1,13 +1,8 @@ import removeShowElement from "./removeShowElement"; -import { removeChild, removeAnimatedChild } from "../../util/removeChild"; +import { removeChild } from "../../util/removeChild"; import { Tour } from "./tour"; -import { - floatingElementClassName, -} from "./classNames"; -import { - queryElementByClassName, - queryElementsByClassName, -} from "../../util/queryElement"; +import { floatingElementClassName } from "./classNames"; +import { queryElementByClassName } from "../../util/queryElement"; /** * Exit from intro @@ -22,39 +17,14 @@ export default async function exitIntro( const targetElement = tour.getTargetElement(); let continueExit: boolean | undefined = true; - // calling onbeforeexit callback - // + // calling the onBeforeExit callback if it is defined // If this callback return `false`, it would halt the process continueExit = await tour.callback("beforeExit")?.call(tour, targetElement); // skip this check if `force` parameter is `true` - // otherwise, if `onbeforeexit` returned `false`, don't exit the intro + // otherwise, if `onBeforEexit` returned `false`, don't exit the intro if (!force && continueExit === false) return false; - // remove overlay layers from the page - // const overlayLayers = Array.from( - // queryElementsByClassName(overlayClassName, targetElement) - // ); - - // if (overlayLayers && overlayLayers.length) { - // for (const overlayLayer of overlayLayers) { - // removeChild(overlayLayer); - // } - // } - - //const referenceLayer = queryElementByClassName( - // tooltipReferenceLayerClassName, - // targetElement - //); - //removeChild(referenceLayer); - - //remove disableInteractionLayer - // const disableInteractionLayer = queryElementByClassName( - // disableInteractionClassName, - // targetElement - // ); - // removeChild(disableInteractionLayer); - //remove intro floating element const floatingElement = queryElementByClassName( floatingElementClassName, @@ -64,13 +34,6 @@ export default async function exitIntro( removeShowElement(); - //remove all helper layers - // const helperLayer = queryElementByClassName( - // helperLayerClassName, - // targetElement - // ); - // await removeAnimatedChild(helperLayer); - //check if any callback is defined await tour.callback("exit")?.call(tour); diff --git a/src/packages/tour/steps.ts b/src/packages/tour/steps.ts index 978eda828..074a06e9b 100644 --- a/src/packages/tour/steps.ts +++ b/src/packages/tour/steps.ts @@ -43,7 +43,13 @@ export type TourStep = { export async function nextStep(tour: Tour) { tour.incrementCurrentStep(); - const nextStep = tour.getStep(tour.getCurrentStep()); + const currentStep = tour.getCurrentStep(); + + if (currentStep === undefined) { + return false; + } + + const nextStep = tour.getStep(currentStep); let continueStep: boolean | undefined = true; continueStep = await tour @@ -55,7 +61,7 @@ export async function nextStep(tour: Tour) { tour.getDirection() ); - // if `onbeforechange` returned `false`, stop displaying the element + // if `onBeforeChange` returned `false`, stop displaying the element if (continueStep === false) { tour.decrementCurrentStep(); return false; @@ -80,13 +86,15 @@ export async function nextStep(tour: Tour) { * @api private */ export async function previousStep(tour: Tour) { - if (tour.getCurrentStep() <= 0) { + const currentStep = tour.getCurrentStep(); + + if (currentStep === undefined || currentStep <= 0) { return false; } tour.decrementCurrentStep(); - const nextStep = tour.getStep(tour.getCurrentStep()); + const nextStep = tour.getStep(currentStep); let continueStep: boolean | undefined = true; continueStep = await tour @@ -98,7 +106,7 @@ export async function previousStep(tour: Tour) { tour.getDirection() ); - // if `onbeforechange` returned `false`, stop displaying the element + // if `onBeforeChange` returned `false`, stop displaying the element if (continueStep === false) { tour.incrementCurrentStep(); return false; From aaedaf087d65834d00db899e2f47785cf41bc7e7 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 09:07:58 +0100 Subject: [PATCH 29/59] Add FloatingElement --- .../tour/components/FloatingElement.ts | 23 +++++++++++++++++++ src/packages/tour/exitIntro.ts | 10 -------- src/packages/tour/steps.ts | 22 ++---------------- src/packages/tour/tour.ts | 23 +++++++++++++++++++ 4 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 src/packages/tour/components/FloatingElement.ts diff --git a/src/packages/tour/components/FloatingElement.ts b/src/packages/tour/components/FloatingElement.ts new file mode 100644 index 000000000..9d56382c9 --- /dev/null +++ b/src/packages/tour/components/FloatingElement.ts @@ -0,0 +1,23 @@ +import van, { State } from "../../dom/van"; +import { floatingElementClassName } from "../classNames"; + +const { div } = van.tags; + +export type FloatingElementProps = { + currentStep: State; +}; + +export const FloatingElement = ({ currentStep }: FloatingElementProps) => { + const floatingElement = div({ + className: floatingElementClassName, + }); + + van.derive(() => { + // meaning the tour has ended so we should remove the floating element + if (currentStep.val === undefined) { + floatingElement.remove(); + } + }); + + return floatingElement; +}; diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index de0e5ff17..3cf38197f 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -1,8 +1,5 @@ import removeShowElement from "./removeShowElement"; -import { removeChild } from "../../util/removeChild"; import { Tour } from "./tour"; -import { floatingElementClassName } from "./classNames"; -import { queryElementByClassName } from "../../util/queryElement"; /** * Exit from intro @@ -25,13 +22,6 @@ export default async function exitIntro( // otherwise, if `onBeforEexit` returned `false`, don't exit the intro if (!force && continueExit === false) return false; - //remove intro floating element - const floatingElement = queryElementByClassName( - floatingElementClassName, - targetElement - ); - removeChild(floatingElement); - removeShowElement(); //check if any callback is defined diff --git a/src/packages/tour/steps.ts b/src/packages/tour/steps.ts index 074a06e9b..2ec711571 100644 --- a/src/packages/tour/steps.ts +++ b/src/packages/tour/steps.ts @@ -1,14 +1,8 @@ import { TooltipPosition } from "../../packages/tooltip"; import showElement from "./showElement"; -import { - queryElement, - queryElementByClassName, - queryElements, -} from "../../util/queryElement"; +import { queryElement, queryElements } from "../../util/queryElement"; import cloneObject from "../../util/cloneObject"; -import createElement from "../../util/createElement"; import { Tour } from "./tour"; -import { floatingElementClassName } from "./classNames"; import { dataDisableInteraction, dataHighlightClass, @@ -143,19 +137,7 @@ export const fetchSteps = (tour: Tour) => { // tour without element if (!step.element) { - let floatingElementQuery = queryElementByClassName( - floatingElementClassName - ); - - if (!floatingElementQuery) { - floatingElementQuery = createElement("div", { - className: floatingElementClassName, - }); - - document.body.appendChild(floatingElementQuery); - } - - step.element = floatingElementQuery; + step.element = tour.appendFloatingElement(); step.position = "floating"; } diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 6f94d22b2..f57bc7922 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -23,6 +23,7 @@ import onKeyDown from "./onKeyDown"; import onResize from "./onResize"; import van from "../dom/van"; import { TourRoot } from "./components/TourRoot"; +import { FloatingElement } from "./components/FloatingElement"; /** * Intro.js Tour class @@ -33,6 +34,7 @@ export class Tour implements Package { private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; + private _floatingElement: Element | undefined; private readonly callbacks: { beforeChange?: introBeforeChangeCallback; @@ -388,6 +390,27 @@ export class Tour implements Package { } } + /** + * Append the floating element to the target element. + * Floating element is a helper element that is used when the step does not have a target element. + * For internal use only. + */ + appendFloatingElement() { + if (!this._floatingElement) { + this._floatingElement = FloatingElement({ + currentStep: this.getCurrentStepSignal(), + }); + + // only add the floating element once per tour instance + van.add(this.getTargetElement(), this._floatingElement); + } + + return this._floatingElement; + } + + /** + * Create the root element for the tour + */ private createRoot() { van.add(this.getTargetElement(), TourRoot({ tour: this })); } From adf01be28d297692d20f741c581f3dde0409587b Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 09:25:42 +0100 Subject: [PATCH 30/59] Fix the showElement bug in previous step --- src/packages/tour/exitIntro.ts | 4 +++- src/packages/tour/removeShowElement.ts | 16 ---------------- src/packages/tour/showElement.ts | 20 ++++++++++++++++++-- src/packages/tour/steps.ts | 10 ++++++++-- 4 files changed, 29 insertions(+), 21 deletions(-) delete mode 100644 src/packages/tour/removeShowElement.ts diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts index 3cf38197f..0ab281198 100644 --- a/src/packages/tour/exitIntro.ts +++ b/src/packages/tour/exitIntro.ts @@ -1,4 +1,4 @@ -import removeShowElement from "./removeShowElement"; +import { removeShowElement } from "./showElement"; import { Tour } from "./tour"; /** @@ -28,6 +28,8 @@ export default async function exitIntro( await tour.callback("exit")?.call(tour); // set the step to default + // this would update the signal to the tour that the tour has ended + // and the corresponding components would be updated tour.resetCurrentStep(); return true; diff --git a/src/packages/tour/removeShowElement.ts b/src/packages/tour/removeShowElement.ts deleted file mode 100644 index 829299190..000000000 --- a/src/packages/tour/removeShowElement.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { queryElementsByClassName } from "../../util/queryElement"; -import { removeClass } from "../../util/className"; -import { showElementClassName } from "./classNames"; - -/** - * To remove all show element(s) - * - * @api private - */ -export default function removeShowElement() { - const elms = Array.from(queryElementsByClassName(showElementClassName)); - - for (const elm of elms) { - removeClass(elm, /introjs-[a-zA-Z]+/g); - } -} diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts index 34bcc1326..83d275932 100644 --- a/src/packages/tour/showElement.ts +++ b/src/packages/tour/showElement.ts @@ -1,8 +1,11 @@ import { addClass } from "../../util/className"; import { TourStep } from "./steps"; -import removeShowElement from "./removeShowElement"; import { Tour } from "./tour"; import getPropValue from "../../util/getPropValue"; +import { queryElementsByClassName } from "../../util/queryElement"; +import { removeClass } from "../../util/className"; +import { showElementClassName } from "./classNames"; + /** * To set the show element * This function set a relative (in most cases) position and changes the z-index @@ -29,7 +32,7 @@ function setShowElement(targetElement: HTMLElement) { * * @api private */ -export default async function _showElement(tour: Tour, step: TourStep) { +export async function showElement(tour: Tour, step: TourStep) { tour.callback("change")?.call(tour, step.element); //remove old classes if the element still exist @@ -39,3 +42,16 @@ export default async function _showElement(tour: Tour, step: TourStep) { await tour.callback("afterChange")?.call(tour, step.element); } + +/** + * To remove all show element(s) + * + * @api private + */ +export function removeShowElement() { + const elms = Array.from(queryElementsByClassName(showElementClassName)); + + for (const elm of elms) { + removeClass(elm, /introjs-[a-zA-Z]+/g); + } +} diff --git a/src/packages/tour/steps.ts b/src/packages/tour/steps.ts index 2ec711571..161ef4b74 100644 --- a/src/packages/tour/steps.ts +++ b/src/packages/tour/steps.ts @@ -1,5 +1,4 @@ import { TooltipPosition } from "../../packages/tooltip"; -import showElement from "./showElement"; import { queryElement, queryElements } from "../../util/queryElement"; import cloneObject from "../../util/cloneObject"; import { Tour } from "./tour"; @@ -14,6 +13,7 @@ import { dataTitleAttribute, dataTooltipClass, } from "./dataAttributes"; +import { showElement } from "./showElement"; export type ScrollTo = "off" | "element" | "tooltip"; @@ -80,13 +80,19 @@ export async function nextStep(tour: Tour) { * @api private */ export async function previousStep(tour: Tour) { - const currentStep = tour.getCurrentStep(); + let currentStep = tour.getCurrentStep(); if (currentStep === undefined || currentStep <= 0) { return false; } tour.decrementCurrentStep(); + // update the current step after decrementing + currentStep = tour.getCurrentStep(); + + if (currentStep === undefined) { + return false; + } const nextStep = tour.getStep(currentStep); let continueStep: boolean | undefined = true; From 41c1f2545d2709c6bdd172904ab1573342c9308f Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 09:29:37 +0100 Subject: [PATCH 31/59] remove unused --- src/util/removeChild.ts | 33 --------------------------------- src/util/removeClass.ts | 0 2 files changed, 33 deletions(-) delete mode 100644 src/util/removeChild.ts delete mode 100644 src/util/removeClass.ts diff --git a/src/util/removeChild.ts b/src/util/removeChild.ts deleted file mode 100644 index 9a2b6232e..000000000 --- a/src/util/removeChild.ts +++ /dev/null @@ -1,33 +0,0 @@ -import setStyle from "./style"; - -/** - * Removes `element` from `parentElement` - */ -export const removeChild = (element: HTMLElement | null) => { - if (!element || !element.parentElement) return; - - element.parentElement.removeChild(element); -}; - -export const removeAnimatedChild = async (element: HTMLElement | null) => { - if (!element) return; - - setStyle(element, { - opacity: "0", - }); - - return new Promise((resolve) => { - setTimeout(() => { - try { - // removeChild(..) throws an exception if the child has already been removed (https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild) - // this try-catch is added to make sure this function doesn't throw an exception if the child has been removed - // this scenario can happen when start()/exit() is called multiple times and the helperLayer is removed by the - // previous exit() call (note: this is a timeout) - removeChild(element); - } catch (e) { - } finally { - resolve(); - } - }, 500); - }); -}; diff --git a/src/util/removeClass.ts b/src/util/removeClass.ts deleted file mode 100644 index e69de29bb..000000000 From 7e2233d8b46f5583022bc314944c4c60cc6f21c1 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 12:34:10 +0100 Subject: [PATCH 32/59] fix resize and refresh steps --- src/packages/tooltip/tooltip.ts | 62 ++++++++++---- src/packages/tooltip/tooltipPosition.ts | 6 +- .../tour/components/DisableInteraction.ts | 19 +++-- src/packages/tour/components/HelperLayer.ts | 5 +- .../tour/components/ReferenceLayer.ts | 17 ++-- src/packages/tour/components/TourRoot.ts | 21 +++-- src/packages/tour/components/TourTooltip.ts | 7 +- src/packages/tour/onResize.ts | 6 -- src/packages/tour/refresh.ts | 84 ------------------- src/packages/tour/tour.ts | 46 ++++++++-- 10 files changed, 134 insertions(+), 139 deletions(-) delete mode 100644 src/packages/tour/onResize.ts delete mode 100644 src/packages/tour/refresh.ts diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index a29c0a326..51b1fe68c 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -1,4 +1,4 @@ -import { Offset } from "../../util/getOffset"; +import getOffset, { Offset } from "../../util/getOffset"; import getWindowSize from "../../util/getWindowSize"; import van, { ChildDom, State } from "../dom/van"; import { arrowClassName, tooltipClassName } from "../tour/classNames"; @@ -121,12 +121,14 @@ function checkRight( width: number; height: number; }, + windowSize: { + width: number; + height: number; + }, tooltipLayerStyleLeft: number, tooltipWidth: number, tooltipLeft: State ): boolean { - const windowSize = getWindowSize(); - if ( targetOffset.left + tooltipLayerStyleLeft + tooltipWidth > windowSize.width @@ -145,6 +147,7 @@ function checkRight( const alignTooltip = ( position: TooltipPosition, targetOffset: { width: number; height: number; left: number; top: number }, + windowSize: { width: number; height: number }, tooltipWidth: number, tooltipHeight: number, tooltipTop: State, @@ -197,6 +200,7 @@ const alignTooltip = ( tooltipRight.val = undefined; checkRight( targetOffset, + windowSize, tooltipLayerStyleLeftRight, tooltipWidth, tooltipLeft @@ -212,6 +216,7 @@ const alignTooltip = ( checkRight( targetOffset, + windowSize, tooltipLayerStyleLeft, tooltipWidth, tooltipLeft @@ -278,6 +283,7 @@ const alignTooltip = ( tooltipRight.val = ""; checkRight( targetOffset, + windowSize, tooltipLayerStyleLeftRight, tooltipWidth, tooltipLeft @@ -291,14 +297,15 @@ const alignTooltip = ( // case 'bottom': // Bottom going to follow the default behavior default: - checkRight(targetOffset, 0, tooltipWidth, tooltipLeft); + checkRight(targetOffset, windowSize, 0, tooltipWidth, tooltipLeft); tooltipTop.val = `${targetOffset.height + 20}px`; } }; export type TooltipProps = { position: TooltipPosition; - targetOffset: Offset; + element: HTMLElement; + refreshes: State; hintMode: boolean; showStepNumbers: boolean; @@ -312,7 +319,8 @@ export type TooltipProps = { export const Tooltip = ( { position: initialPosition, - targetOffset, + element, + refreshes, hintMode = false, showStepNumbers = false, @@ -332,30 +340,45 @@ export const Tooltip = ( const marginTop = van.state("auto"); const opacity = van.state(0); // setting a default height for the tooltip instead of 0 to avoid flickering - const tooltipHeight = van.state(150); + // this default is coming from the CSS class and is overridden after the tooltip is rendered + const tooltipHeight = van.state(250); // max width of the tooltip according to its CSS class + // this default is coming from the CSS class and is overridden after the tooltip is rendered const tooltipWidth = van.state(300); const position = van.state(initialPosition); - const windowSize = getWindowSize(); + // windowSize can change if the window is resized + const windowSize = van.state(getWindowSize()); + const targetOffset = van.state(getOffset(element)); const tooltipBottomOverflow = van.derive( - () => targetOffset.top + tooltipHeight.val! > windowSize.height + () => targetOffset.val!.top + tooltipHeight.val! > windowSize.val!.height ); + van.derive(() => { + // set the new windowSize and targetOffset if the refreshes signal changes + if (refreshes.val !== undefined) { + windowSize.val = getWindowSize(); + targetOffset.val = getOffset(element); + } + }); + // auto-align tooltip based on position precedence and target offset van.derive(() => { if ( position.val !== undefined && - position.val !== "floating" && + initialPosition !== "floating" && autoPosition && tooltipWidth.val && - tooltipHeight.val + tooltipHeight.val && + targetOffset.val && + windowSize.val ) { position.val = determineAutoPosition( positionPrecedence, - targetOffset, + targetOffset.val, tooltipWidth.val, tooltipHeight.val, - position.val + initialPosition, + windowSize.val ); } }); @@ -366,11 +389,14 @@ export const Tooltip = ( tooltipWidth.val !== undefined && tooltipHeight.val !== undefined && tooltipBottomOverflow.val !== undefined && - position.val !== undefined + position.val !== undefined && + targetOffset.val !== undefined && + windowSize.val !== undefined ) { alignTooltip( position.val, - targetOffset, + targetOffset.val, + windowSize.val, tooltipWidth.val, tooltipHeight.val, top, @@ -407,5 +433,11 @@ export const Tooltip = ( opacity.val = 1; }, transitionDuration); + setTimeout(() => { + // set the correct height and width of the tooltip after it has been rendered + tooltipHeight.val = tooltip.offsetHeight; + tooltipWidth.val = tooltip.offsetWidth; + }, 1); + return tooltip; }; diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts index be6d7741d..df66aa2a0 100644 --- a/src/packages/tooltip/tooltipPosition.ts +++ b/src/packages/tooltip/tooltipPosition.ts @@ -1,4 +1,3 @@ -import getWindowSize from "../../util/getWindowSize"; import removeEntry from "../../util/removeEntry"; import { Offset } from "../../util/getOffset"; @@ -67,13 +66,12 @@ export function determineAutoPosition( targetOffset: Offset, tooltipWidth: number, tooltipHeight: number, - desiredTooltipPosition: TooltipPosition + desiredTooltipPosition: TooltipPosition, + windowSize: { width: number; height: number } ): TooltipPosition { // Take a clone of position precedence. These will be the available const possiblePositions = positionPrecedence.slice(); - const windowSize = getWindowSize(); - // Add some padding to the tooltip height and width for better positioning tooltipHeight = tooltipHeight + 10; tooltipWidth = tooltipWidth + 20; diff --git a/src/packages/tour/components/DisableInteraction.ts b/src/packages/tour/components/DisableInteraction.ts index e70b270f3..50c9b974a 100644 --- a/src/packages/tour/components/DisableInteraction.ts +++ b/src/packages/tour/components/DisableInteraction.ts @@ -8,6 +8,7 @@ const { div } = van.tags; export type HelperLayerProps = { currentStep: State; steps: TourStep[]; + refreshes: State; targetElement: HTMLElement; helperElementPadding: number; }; @@ -15,6 +16,7 @@ export type HelperLayerProps = { export const DisableInteraction = ({ currentStep, steps, + refreshes, targetElement, helperElementPadding, }: HelperLayerProps) => { @@ -31,12 +33,17 @@ export const DisableInteraction = ({ className: disableInteractionClassName, }); - setPositionRelativeToStep( - targetElement, - disableInteraction, - step.val, - helperElementPadding - ); + van.derive(() => { + // set the position of the reference layer if the refreshes signal changes + if (!step.val || refreshes.val == undefined) return; + + setPositionRelativeToStep( + targetElement, + disableInteraction, + step.val, + helperElementPadding + ); + }); return disableInteraction; }; diff --git a/src/packages/tour/components/HelperLayer.ts b/src/packages/tour/components/HelperLayer.ts index 05c200626..821a917c2 100644 --- a/src/packages/tour/components/HelperLayer.ts +++ b/src/packages/tour/components/HelperLayer.ts @@ -31,6 +31,7 @@ const getClassName = ({ export type HelperLayerProps = { currentStep: State; steps: TourStep[]; + refreshes: State; targetElement: HTMLElement; tourHighlightClass: string; overlayOpacity: number; @@ -40,6 +41,7 @@ export type HelperLayerProps = { export const HelperLayer = ({ currentStep, steps, + refreshes, targetElement, tourHighlightClass, overlayOpacity, @@ -59,7 +61,8 @@ export const HelperLayer = ({ }); van.derive(() => { - if (!step.val) return; + // set the new position if the step or refreshes change + if (!step.val || refreshes.val === undefined) return; setPositionRelativeToStep( targetElement, diff --git a/src/packages/tour/components/ReferenceLayer.ts b/src/packages/tour/components/ReferenceLayer.ts index 74fee132d..516e3712e 100644 --- a/src/packages/tour/components/ReferenceLayer.ts +++ b/src/packages/tour/components/ReferenceLayer.ts @@ -22,12 +22,17 @@ export const ReferenceLayer = ({ TourTooltip(props) ); - setPositionRelativeToStep( - targetElement, - referenceLayer, - props.step, - helperElementPadding - ); + van.derive(() => { + // set the position of the reference layer if the refreshes signal changes + if (props.refreshes.val == undefined) return; + + setPositionRelativeToStep( + targetElement, + referenceLayer, + props.step, + helperElementPadding + ); + }); return referenceLayer; }; diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index 2754b716f..52bb69c11 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -15,12 +15,14 @@ export type TourRootProps = { }; export const TourRoot = ({ tour }: TourRootProps) => { - const currentStep = tour.getCurrentStepSignal(); + const currentStepSignal = tour.getCurrentStepSignal(); + const refreshesSignal = tour.getRefreshesSignal(); const steps = tour.getSteps(); const helperLayer = HelperLayer({ - currentStep, + currentStep: currentStepSignal, steps, + refreshes: refreshesSignal, targetElement: tour.getTargetElement(), tourHighlightClass: tour.getOption("highlightClass"), overlayOpacity: tour.getOption("overlayOpacity"), @@ -42,12 +44,14 @@ export const TourRoot = ({ tour }: TourRootProps) => { () => { // do not remove this check, it is necessary for this state-binding to work // and render the entire section every time the state changes - if (currentStep.val === undefined) { + if (currentStepSignal.val === undefined) { return null; } const step = van.derive(() => - currentStep.val !== undefined ? steps[currentStep.val] : null + currentStepSignal.val !== undefined + ? steps[currentStepSignal.val] + : null ); if (!step.val) { @@ -65,6 +69,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { const referenceLayer = ReferenceLayer({ step: step.val, targetElement: tour.getTargetElement(), + refreshes: refreshesSignal, helperElementPadding: tour.getOption("helperElementPadding"), transitionDuration: tooltipTransitionDuration, @@ -74,7 +79,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { showStepNumbers: tour.getOption("showStepNumbers"), steps: tour.getSteps(), - currentStep: currentStep.val, + currentStep: currentStepSignal.val, onBulletClick: (stepNumber: number) => { tour.goToStep(stepNumber); @@ -144,8 +149,9 @@ export const TourRoot = ({ tour }: TourRootProps) => { const disableInteraction = step.val.disableInteraction ? DisableInteraction({ - currentStep, + currentStep: currentStepSignal, steps: tour.getSteps(), + refreshes: refreshesSignal, targetElement: tour.getTargetElement(), helperElementPadding: tour.getOption("helperElementPadding"), }) @@ -162,7 +168,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { van.derive(() => { // to clean up the root element when the tour is done - if (currentStep.val === undefined) { + if (currentStepSignal.val === undefined) { opacity.val = 0; setTimeout(() => { @@ -172,6 +178,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { }); setTimeout(() => { + // fade in the root element opacity.val = 1; }, 1); diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 0296540ce..903e2d087 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -1,5 +1,5 @@ import { Tooltip, type TooltipProps } from "../../tooltip/tooltip"; -import van, { PropValueOrDerived } from "../../dom/van"; +import van, { PropValueOrDerived, State } from "../../dom/van"; import { activeClassName, bulletsClassName, @@ -372,7 +372,7 @@ const scroll = ({ export type TourTooltipProps = Omit< TooltipProps, - "hintMode" | "position" | "targetOffset" + "hintMode" | "position" | "element" > & { step: TourStep; steps: TourStep[]; @@ -448,7 +448,6 @@ export const TourTooltip = ({ const title = step.title; const text = step.intro; const position = step.position; - const targetOffset = getOffset(step.element as HTMLElement); children.push(Header({ title, skipLabel, onSkipClick })); @@ -496,9 +495,9 @@ export const TourTooltip = ({ const tooltip = Tooltip( { ...props, + element: step.element as HTMLElement, hintMode: false, position, - targetOffset, }, children ); diff --git a/src/packages/tour/onResize.ts b/src/packages/tour/onResize.ts deleted file mode 100644 index 31e011dcd..000000000 --- a/src/packages/tour/onResize.ts +++ /dev/null @@ -1,6 +0,0 @@ -import refresh from "./refresh"; -import { Tour } from "./tour"; - -export default function onResize(tour: Tour) { - refresh(tour); -} diff --git a/src/packages/tour/refresh.ts b/src/packages/tour/refresh.ts deleted file mode 100644 index 2181c17fc..000000000 --- a/src/packages/tour/refresh.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { placeTooltip } from "../../packages/tooltip"; -import { Tour } from "./tour"; -import { - getElementByClassName, - queryElementByClassName, -} from "../../util/queryElement"; -import { - disableInteractionClassName, - helperLayerClassName, - tooltipReferenceLayerClassName, -} from "./classNames"; -import { setPositionRelativeToStep } from "./position"; -import { fetchSteps } from "./steps"; - -/** - * Update placement of the intro objects on the screen - * @api private - */ -export default function refresh(tour: Tour, refreshSteps?: boolean) { - const currentStep = tour.getCurrentStep(); - - if (currentStep === undefined || currentStep === null || currentStep == -1) { - return; - } - - const step = tour.getStep(currentStep); - - const referenceLayer = getElementByClassName(tooltipReferenceLayerClassName); - const helperLayer = getElementByClassName(helperLayerClassName); - const disableInteractionLayer = queryElementByClassName( - disableInteractionClassName - ); - - // re-align intros - const targetElement = tour.getTargetElement(); - const helperLayerPadding = tour.getOption("helperElementPadding"); - setPositionRelativeToStep( - targetElement, - helperLayer, - step, - helperLayerPadding - ); - setPositionRelativeToStep( - targetElement, - referenceLayer, - step, - helperLayerPadding - ); - - // not all steps have a disableInteractionLayer - if (disableInteractionLayer) { - setPositionRelativeToStep( - targetElement, - disableInteractionLayer, - step, - helperLayerPadding - ); - } - - if (refreshSteps) { - tour.setSteps(fetchSteps(tour)); - // TODO: how to refresh the tooltip here? do we need to convert the steps into a state? - } - - // re-align tooltip - const oldArrowLayer = document.querySelector(".introjs-arrow"); - const oldTooltipContainer = - document.querySelector(".introjs-tooltip"); - - if (oldTooltipContainer && oldArrowLayer) { - placeTooltip( - oldTooltipContainer, - oldArrowLayer, - step.element as HTMLElement, - step.position, - tour.getOption("positionPrecedence"), - tour.getOption("showStepNumbers"), - tour.getOption("autoPosition"), - step.tooltipClass ?? tour.getOption("tooltipClass") - ); - } - - return tour; -} diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index f57bc7922..72ba003d3 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -1,4 +1,4 @@ -import { nextStep, previousStep, TourStep } from "./steps"; +import { fetchSteps, nextStep, previousStep, TourStep } from "./steps"; import { Package } from "../package"; import { introAfterChangeCallback, @@ -16,11 +16,9 @@ import { start } from "./start"; import exitIntro from "./exitIntro"; import isFunction from "../../util/isFunction"; import { getDontShowAgain, setDontShowAgain } from "./dontShowAgain"; -import refresh from "./refresh"; import { getContainerElement } from "../../util/containerElement"; import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; -import onResize from "./onResize"; import van from "../dom/van"; import { TourRoot } from "./components/TourRoot"; import { FloatingElement } from "./components/FloatingElement"; @@ -31,6 +29,8 @@ import { FloatingElement } from "./components/FloatingElement"; export class Tour implements Package { private _steps: TourStep[] = []; private _currentStep = van.state(undefined); + private _refreshes = van.state(0); + private _root: Element | undefined; private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; private _options: TourOptions; @@ -173,6 +173,14 @@ export class Tour implements Package { return this._currentStep; } + /** + * Returns the underlying state of the refreshes + * This is an internal method and should not be used outside of the package. + */ + getRefreshesSignal() { + return this._refreshes; + } + /** * Get the current step of the tour */ @@ -376,7 +384,7 @@ export class Tour implements Package { * Enable refresh on window resize for the tour */ enableRefreshOnResize() { - this._refreshOnResizeHandler = (_: Event) => onResize(this); + this._refreshOnResizeHandler = (_: Event) => this.refresh(); DOMEvent.on(window, "resize", this._refreshOnResizeHandler, true); } @@ -412,7 +420,18 @@ export class Tour implements Package { * Create the root element for the tour */ private createRoot() { - van.add(this.getTargetElement(), TourRoot({ tour: this })); + this._root = TourRoot({ tour: this }); + van.add(this.getTargetElement(), this._root); + } + + /** + * Deletes the root element and recreates it + */ + private recreateRoot() { + if (this._root) { + this._root.remove(); + this.createRoot(); + } } /** @@ -446,7 +465,22 @@ export class Tour implements Package { * @param {boolean} refreshSteps whether to refresh the tour steps */ refresh(refreshSteps?: boolean) { - refresh(this, refreshSteps); + const currentStep = this.getCurrentStep(); + + if (currentStep === undefined) { + return this; + } + + if (this._refreshes.val !== undefined) { + this._refreshes.val += 1; + } + + // fetch new steps and recreate the root element + if (refreshSteps) { + this.setSteps(fetchSteps(this)); + this.recreateRoot(); + } + return this; } From edc92ca20246cec4d7d8cc764516dc685569cdc5 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 12:45:50 +0100 Subject: [PATCH 33/59] remove unused placeTooltip --- src/packages/tooltip/index.ts | 1 - src/packages/tooltip/placeTooltip.ts | 369 --------------------------- 2 files changed, 370 deletions(-) delete mode 100644 src/packages/tooltip/placeTooltip.ts diff --git a/src/packages/tooltip/index.ts b/src/packages/tooltip/index.ts index ca0d99c31..4d1ed76c7 100644 --- a/src/packages/tooltip/index.ts +++ b/src/packages/tooltip/index.ts @@ -1,2 +1 @@ export { TooltipPosition } from "./tooltipPosition"; -export { placeTooltip } from "./placeTooltip"; diff --git a/src/packages/tooltip/placeTooltip.ts b/src/packages/tooltip/placeTooltip.ts deleted file mode 100644 index 08b02a24c..000000000 --- a/src/packages/tooltip/placeTooltip.ts +++ /dev/null @@ -1,369 +0,0 @@ -import getOffset from "../../util/getOffset"; -import getWindowSize from "../../util/getWindowSize"; -import { addClass, setClass } from "../../util/className"; -import checkRight from "../../util/checkRight"; -import checkLeft from "../../util/checkLeft"; -import removeEntry from "../../util/removeEntry"; -import { TooltipPosition } from "./tooltipPosition"; - -/** - * auto-determine alignment - */ -function _determineAutoAlignment( - offsetLeft: number, - tooltipWidth: number, - windowWidth: number, - desiredAlignment: TooltipPosition[] -): TooltipPosition | null { - const halfTooltipWidth = tooltipWidth / 2; - const winWidth = Math.min(windowWidth, window.screen.width); - - // valid left must be at least a tooltipWidth - // away from right side - if (winWidth - offsetLeft < tooltipWidth) { - removeEntry(desiredAlignment, "top-left-aligned"); - removeEntry(desiredAlignment, "bottom-left-aligned"); - } - - // valid middle must be at least half - // width away from both sides - if ( - offsetLeft < halfTooltipWidth || - winWidth - offsetLeft < halfTooltipWidth - ) { - removeEntry(desiredAlignment, "top-middle-aligned"); - removeEntry(desiredAlignment, "bottom-middle-aligned"); - } - - // valid right must be at least a tooltipWidth - // width away from left side - if (offsetLeft < tooltipWidth) { - removeEntry(desiredAlignment, "top-right-aligned"); - removeEntry(desiredAlignment, "bottom-right-aligned"); - } - - if (desiredAlignment.length) { - return desiredAlignment[0]; - } - - return null; -} - -/** - * Determines the position of the tooltip based on the position precedence and availability - * of screen space. - */ -function _determineAutoPosition( - positionPrecedence: TooltipPosition[], - targetElement: HTMLElement, - tooltipLayer: HTMLElement, - desiredTooltipPosition: TooltipPosition -): TooltipPosition { - // Take a clone of position precedence. These will be the available - const possiblePositions = positionPrecedence.slice(); - - const windowSize = getWindowSize(); - const tooltipHeight = getOffset(tooltipLayer).height + 10; - const tooltipWidth = getOffset(tooltipLayer).width + 20; - const targetElementRect = targetElement.getBoundingClientRect(); - - // If we check all the possible areas, and there are no valid places for the tooltip, the element - // must take up most of the screen real estate. Show the tooltip floating in the middle of the screen. - let calculatedPosition: TooltipPosition = "floating"; - - /* - * auto determine position - */ - - // Check for space below - if (targetElementRect.bottom + tooltipHeight > windowSize.height) { - removeEntry(possiblePositions, "bottom"); - } - - // Check for space above - if (targetElementRect.top - tooltipHeight < 0) { - removeEntry(possiblePositions, "top"); - } - - // Check for space to the right - if (targetElementRect.right + tooltipWidth > windowSize.width) { - removeEntry(possiblePositions, "right"); - } - - // Check for space to the left - if (targetElementRect.left - tooltipWidth < 0) { - removeEntry(possiblePositions, "left"); - } - - // strip alignment from position - if (desiredTooltipPosition) { - // ex: "bottom-right-aligned" - // should return 'bottom' - desiredTooltipPosition = desiredTooltipPosition.split( - "-" - )[0] as TooltipPosition; - } - - if (possiblePositions.length) { - // Pick the first valid position, in order - calculatedPosition = possiblePositions[0]; - - if (possiblePositions.includes(desiredTooltipPosition)) { - // If the requested position is in the list, choose that - calculatedPosition = desiredTooltipPosition; - } - } - - // only "top" and "bottom" positions have optional alignments - if (calculatedPosition === "top" || calculatedPosition === "bottom") { - let defaultAlignment: TooltipPosition; - let desiredAlignment: TooltipPosition[] = []; - - if (calculatedPosition === "top") { - // if screen width is too small - // for ANY alignment, middle is - // probably the best for visibility - defaultAlignment = "top-middle-aligned"; - - desiredAlignment = [ - "top-left-aligned", - "top-middle-aligned", - "top-right-aligned", - ]; - } else { - defaultAlignment = "bottom-middle-aligned"; - - desiredAlignment = [ - "bottom-left-aligned", - "bottom-middle-aligned", - "bottom-right-aligned", - ]; - } - - calculatedPosition = - _determineAutoAlignment( - targetElementRect.left, - tooltipWidth, - windowSize.width, - desiredAlignment - ) || defaultAlignment; - } - - return calculatedPosition; -} - -/** - * Render tooltip box in the page - * - * @api private - */ -export const placeTooltip = ( - tooltipLayer: HTMLElement, - arrowLayer: HTMLElement, - targetElement: HTMLElement, - position: TooltipPosition, - positionPrecedence: TooltipPosition[], - showStepNumbers = false, - autoPosition = true, - tooltipClassName = "", - hintMode: boolean = false -) => { - let tooltipOffset: { - top: number; - left: number; - width: number; - height: number; - }; - - //reset the old style - tooltipLayer.style.top = ""; - tooltipLayer.style.right = ""; - tooltipLayer.style.bottom = ""; - tooltipLayer.style.left = ""; - tooltipLayer.style.marginLeft = ""; - tooltipLayer.style.marginTop = ""; - - arrowLayer.style.display = "inherit"; - - setClass(tooltipLayer, "introjs-tooltip", tooltipClassName); - - tooltipLayer.setAttribute("role", "dialog"); - - // Floating is always valid, no point in calculating - if (position !== "floating" && autoPosition) { - position = _determineAutoPosition( - positionPrecedence, - targetElement, - tooltipLayer, - position - ); - } - - let tooltipLayerStyleLeft: number; - let targetOffset = getOffset(targetElement as HTMLElement); - tooltipOffset = getOffset(tooltipLayer); - let windowSize = getWindowSize(); - - addClass(tooltipLayer, `introjs-${position}`); - - let tooltipLayerStyleLeftRight = - targetOffset.width / 2 - tooltipOffset.width / 2; - - switch (position) { - case "top-right-aligned": - setClass(arrowLayer, "introjs-arrow bottom-right"); - - let tooltipLayerStyleRight = 0; - checkLeft( - targetOffset, - tooltipLayerStyleRight, - tooltipOffset, - tooltipLayer - ); - tooltipLayer.style.bottom = `${targetOffset.height + 20}px`; - break; - - case "top-middle-aligned": - setClass(arrowLayer, "introjs-arrow bottom-middle"); - - // a fix for middle aligned hints - if (hintMode) { - tooltipLayerStyleLeftRight += 5; - } - - if ( - checkLeft( - targetOffset, - tooltipLayerStyleLeftRight, - tooltipOffset, - tooltipLayer - ) - ) { - tooltipLayer.style.right = ""; - checkRight( - targetOffset, - tooltipLayerStyleLeftRight, - tooltipOffset, - windowSize, - tooltipLayer - ); - } - tooltipLayer.style.bottom = `${targetOffset.height + 20}px`; - break; - - case "top-left-aligned": - // top-left-aligned is the same as the default top - case "top": - setClass(arrowLayer, "introjs-arrow bottom"); - - tooltipLayerStyleLeft = hintMode ? 0 : 15; - - checkRight( - targetOffset, - tooltipLayerStyleLeft, - tooltipOffset, - windowSize, - tooltipLayer - ); - tooltipLayer.style.bottom = `${targetOffset.height + 20}px`; - break; - case "right": - tooltipLayer.style.left = `${targetOffset.width + 20}px`; - if (targetOffset.top + tooltipOffset.height > windowSize.height) { - // In this case, right would have fallen below the bottom of the screen. - // Modify so that the bottom of the tooltip connects with the target - setClass(arrowLayer, "introjs-arrow left-bottom"); - tooltipLayer.style.top = `-${ - tooltipOffset.height - targetOffset.height - 20 - }px`; - } else { - setClass(arrowLayer, "introjs-arrow left"); - } - break; - case "left": - if (!hintMode && showStepNumbers === true) { - tooltipLayer.style.top = "15px"; - } - - if (targetOffset.top + tooltipOffset.height > windowSize.height) { - // In this case, left would have fallen below the bottom of the screen. - // Modify so that the bottom of the tooltip connects with the target - tooltipLayer.style.top = `-${ - tooltipOffset.height - targetOffset.height - 20 - }px`; - setClass(arrowLayer, "introjs-arrow right-bottom"); - } else { - setClass(arrowLayer, "introjs-arrow right"); - } - tooltipLayer.style.right = `${targetOffset.width + 20}px`; - - break; - case "floating": - arrowLayer.style.display = "none"; - - //we have to adjust the top and left of layer manually for intro items without element - tooltipLayer.style.left = "50%"; - tooltipLayer.style.top = "50%"; - tooltipLayer.style.marginLeft = `-${tooltipOffset.width / 2}px`; - tooltipLayer.style.marginTop = `-${tooltipOffset.height / 2}px`; - - break; - case "bottom-right-aligned": - setClass(arrowLayer, "introjs-arrow top-right"); - - tooltipLayerStyleRight = 0; - checkLeft( - targetOffset, - tooltipLayerStyleRight, - tooltipOffset, - tooltipLayer - ); - tooltipLayer.style.top = `${targetOffset.height + 20}px`; - break; - - case "bottom-middle-aligned": - setClass(arrowLayer, "introjs-arrow top-middle"); - - // a fix for middle aligned hints - if (hintMode) { - tooltipLayerStyleLeftRight += 5; - } - - if ( - checkLeft( - targetOffset, - tooltipLayerStyleLeftRight, - tooltipOffset, - tooltipLayer - ) - ) { - tooltipLayer.style.right = ""; - checkRight( - targetOffset, - tooltipLayerStyleLeftRight, - tooltipOffset, - windowSize, - tooltipLayer - ); - } - tooltipLayer.style.top = `${targetOffset.height + 20}px`; - break; - - // case 'bottom-left-aligned': - // Bottom-left-aligned is the same as the default bottom - // case 'bottom': - // Bottom going to follow the default behavior - default: - setClass(arrowLayer, "introjs-arrow top"); - - tooltipLayerStyleLeft = 0; - checkRight( - targetOffset, - tooltipLayerStyleLeft, - tooltipOffset, - windowSize, - tooltipLayer - ); - tooltipLayer.style.top = `${targetOffset.height + 20}px`; - } -}; From 9c6b72b7c0dddf24615287d3a718fac236163d87 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 15:55:59 +0100 Subject: [PATCH 34/59] HintsRoot --- src/packages/hint/components/HintIcon.ts | 59 +++++++++ .../HintTooltip.ts} | 7 +- src/packages/hint/components/HintsRoot.ts | 86 +++++++++++++ .../hint/components/ReferenceLayer.ts | 48 ++++++++ src/packages/hint/hide.ts | 2 +- src/packages/hint/hint.ts | 10 ++ src/packages/hint/render.ts | 85 ++++++------- src/packages/hint/tooltip.ts | 115 +++++++++--------- 8 files changed, 311 insertions(+), 101 deletions(-) create mode 100644 src/packages/hint/components/HintIcon.ts rename src/packages/hint/{hintTooltip.ts => components/HintTooltip.ts} (81%) create mode 100644 src/packages/hint/components/HintsRoot.ts create mode 100644 src/packages/hint/components/ReferenceLayer.ts diff --git a/src/packages/hint/components/HintIcon.ts b/src/packages/hint/components/HintIcon.ts new file mode 100644 index 000000000..0e0ba7470 --- /dev/null +++ b/src/packages/hint/components/HintIcon.ts @@ -0,0 +1,59 @@ +import isFixed from "../../../util/isFixed"; +import van from "../../dom/van"; +import { + fixedHintClassName, + hintClassName, + hintDotClassName, + hintNoAnimationClassName, + hintPulseClassName, +} from "../className"; +import { HintItem, HintPosition } from "../hintItem"; +import { dataStepAttribute } from "../dataAttributes"; +import { alignHintPosition } from "../position"; + +const { a, div } = van.tags; + +export type HintProps = { + index: number; + hintItem: HintItem; + onClick: (e: any) => void; +}; + +const className = (hintItem: HintItem) => { + const classNames = [hintClassName]; + + if (!hintItem.hintAnimation) { + classNames.push(hintNoAnimationClassName); + } + + if (isFixed(hintItem.element as HTMLElement)) { + classNames.push(fixedHintClassName); + } + + return classNames.join(" "); +}; + +const HintDot = () => div({ className: hintDotClassName }); +const HintPulse = () => div({ className: hintPulseClassName }); + +export const HintIcon = ({ index, hintItem, onClick }: HintProps) => { + const hintElement = a( + { + [dataStepAttribute]: index.toString(), + className: className(hintItem), + role: "button", + tabindex: 0, + onclick: onClick, + }, + HintDot(), + HintPulse() + ); + + alignHintPosition( + hintItem.hintPosition as HintPosition, + hintElement, + hintItem.element as HTMLElement + ); + + return hintElement; +}; diff --git a/src/packages/hint/hintTooltip.ts b/src/packages/hint/components/HintTooltip.ts similarity index 81% rename from src/packages/hint/hintTooltip.ts rename to src/packages/hint/components/HintTooltip.ts index 0f83426dc..c07b6e330 100644 --- a/src/packages/hint/hintTooltip.ts +++ b/src/packages/hint/components/HintTooltip.ts @@ -1,6 +1,7 @@ -import { Tooltip, TooltipProps } from "../tooltip/tooltip"; -import van from "../dom/van"; -import { tooltipTextClassName } from "./className"; +import { Tooltip, TooltipProps } from "../../tooltip/tooltip"; +import van from "../../dom/van"; +import { tooltipTextClassName } from "../className"; +import { HintItem } from "../hintItem"; const { a, p, div } = van.tags; diff --git a/src/packages/hint/components/HintsRoot.ts b/src/packages/hint/components/HintsRoot.ts new file mode 100644 index 000000000..aac3f4886 --- /dev/null +++ b/src/packages/hint/components/HintsRoot.ts @@ -0,0 +1,86 @@ +import van from "../../dom/van"; +import { hintsClassName } from "../className"; +import { hideHint } from "../hide"; +import { Hint } from "../hint"; +import { showHintDialog } from "../tooltip"; +import { HintIcon } from "./HintIcon"; +import { ReferenceLayer } from "./ReferenceLayer"; + +const { div } = van.tags; + +export type HintsRootProps = { + hint: Hint; +}; + +/** + * Returns an event handler unique to the hint iteration + */ +const getHintClick = (hint: Hint, i: number) => (e: Event) => { + const evt = e ? e : window.event; + + if (evt && evt.stopPropagation) { + evt.stopPropagation(); + } + + if (evt && evt.cancelBubble !== null) { + evt.cancelBubble = true; + } + + showHintDialog(hint, i); +}; + +export const HintsRoot = ({ hint }: HintsRootProps) => { + const hintElements = []; + + for (const [i, hintItem] of hint.getHints().entries()) { + hintElements.push( + HintIcon({ + index: i, + hintItem, + onClick: getHintClick(hint, i), + }) + ); + } + + const root = div( + { + className: hintsClassName, + }, + ...hintElements + ); + + van.derive(() => { + if (hint._activeHintSignal.val === undefined) return; + + const stepId = hint._activeHintSignal.val; + const hints = hint.getHints(); + const hintItem = hints[stepId]; + + const referenceLayer = ReferenceLayer({ + activeHintSignal: hint._activeHintSignal, + text: hintItem.hint || "", + element: hintItem.element as HTMLElement, + position: hintItem.position, + + helperElementPadding: hint.getOption("helperElementPadding"), + targetElement: hint.getTargetElement(), + + refreshes: hint._refreshes, + + // hints don't have step numbers + showStepNumbers: false, + + autoPosition: hint.getOption("autoPosition"), + positionPrecedence: hint.getOption("positionPrecedence"), + + closeButtonEnabled: hint.getOption("hintShowButton"), + closeButtonLabel: hint.getOption("hintButtonLabel"), + closeButtonClassName: hint.getOption("buttonClass"), + closeButtonOnClick: () => hideHint(hint, stepId), + }); + + van.add(root, referenceLayer); + }); + + return root; +}; diff --git a/src/packages/hint/components/ReferenceLayer.ts b/src/packages/hint/components/ReferenceLayer.ts new file mode 100644 index 000000000..3726e6355 --- /dev/null +++ b/src/packages/hint/components/ReferenceLayer.ts @@ -0,0 +1,48 @@ +import { setPositionRelativeTo } from "../../../util/positionRelativeTo"; +import van, { State } from "../../dom/van"; +import { + hintReferenceClassName, + tooltipReferenceLayerClassName, +} from "../className"; +import { dataStepAttribute } from "../dataAttributes"; +import { HintTooltip, HintTooltipProps } from "./HintTooltip"; + +const { div } = van.tags; + +export type ReferenceLayerProps = HintTooltipProps & { + activeHintSignal: State; + targetElement: HTMLElement; + helperElementPadding: number; +}; + +export const ReferenceLayer = ({ + activeHintSignal, + targetElement, + helperElementPadding, + ...props +}: ReferenceLayerProps) => { + return () => { + // remove the reference layer if the active hint signal is set to undefined + // e.g. when the user clicks outside the hint + if (activeHintSignal.val == undefined) return null; + + const referenceLayer = div( + { + [dataStepAttribute]: activeHintSignal.val, + className: `${tooltipReferenceLayerClassName} ${hintReferenceClassName}`, + }, + HintTooltip(props) + ); + + setTimeout(() => { + setPositionRelativeTo( + targetElement, + referenceLayer, + props.element as HTMLElement, + helperElementPadding + ); + }, 1); + + return referenceLayer; + }; +}; diff --git a/src/packages/hint/hide.ts b/src/packages/hint/hide.ts index 029007339..d7b183434 100644 --- a/src/packages/hint/hide.ts +++ b/src/packages/hint/hide.ts @@ -13,7 +13,7 @@ import { hintElement, hintElements } from "./selector"; export async function hideHint(hint: Hint, stepId: number) { const element = hintElement(stepId); - removeHintTooltip(); + //removeHintTooltip(); if (element) { addClass(element, hideHintClassName); diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index 29819ff21..df35aa6e7 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -12,6 +12,8 @@ import { hideHint, hideHints } from "./hide"; import { showHint, showHints } from "./show"; import { removeHint, removeHints } from "./remove"; import { showHintDialog } from "./tooltip"; +import van from "../dom/van"; +import { HintsRoot } from "./components/HintsRoot"; type hintsAddedCallback = (this: Hint) => void | Promise; type hintClickCallback = ( @@ -26,6 +28,8 @@ export class Hint implements Package { private _hints: HintItem[] = []; private readonly _targetElement: HTMLElement; private _options: HintOptions; + public _activeHintSignal = van.state(undefined); + public _refreshes = van.state(0); private readonly callbacks: { hintsAdded?: hintsAddedCallback; @@ -105,6 +109,10 @@ export class Hint implements Package { return this; } + private createRoot() { + van.add(this._targetElement, HintsRoot({ hint: this })); + } + /** * Render hints on the page */ @@ -115,6 +123,7 @@ export class Hint implements Package { fetchHintItems(this); await renderHints(this); + this.createRoot(); return this; } @@ -193,6 +202,7 @@ export class Hint implements Package { * @param stepId The hint step ID */ async showHintDialog(stepId: number) { + this._activeHintSignal.val = stepId; await showHintDialog(this, stepId); return this; } diff --git a/src/packages/hint/render.ts b/src/packages/hint/render.ts index 92e71a86b..e5da1e301 100644 --- a/src/packages/hint/render.ts +++ b/src/packages/hint/render.ts @@ -16,6 +16,7 @@ import { addClass } from "../../util/className"; import isFixed from "../../util/isFixed"; import { alignHintPosition } from "./position"; import { showHintDialog } from "./tooltip"; +import { HintsRoot } from "./components/HintsRoot"; /** * Returns an event handler unique to the hint iteration @@ -48,60 +49,62 @@ export async function renderHints(hint: Hint) { }); } - const hints = hint.getHints(); - for (let i = 0; i < hints.length; i++) { - const hintItem = hints[i]; + //const hints = hint.getHints(); + //for (let i = 0; i < hints.length; i++) { + // const hintItem = hints[i]; - // avoid append a hint twice - if (queryElement(`.${hintClassName}[${dataStepAttribute}="${i}"]`)) { - return; - } + // // avoid append a hint twice + // if (queryElement(`.${hintClassName}[${dataStepAttribute}="${i}"]`)) { + // return; + // } - const hintElement = createElement("a", { - className: hintClassName, - }); - setAnchorAsButton(hintElement); + // const hintElement = createElement("a", { + // className: hintClassName, + // }); + // setAnchorAsButton(hintElement); - hintElement.onclick = getHintClick(hint, i); + // hintElement.onclick = getHintClick(hint, i); - if (!hintItem.hintAnimation) { - addClass(hintElement, hintNoAnimationClassName); - } + // if (!hintItem.hintAnimation) { + // addClass(hintElement, hintNoAnimationClassName); + // } - // hint's position should be fixed if the target element's position is fixed - if (isFixed(hintItem.element as HTMLElement)) { - addClass(hintElement, fixedHintClassName); - } + // // hint's position should be fixed if the target element's position is fixed + // if (isFixed(hintItem.element as HTMLElement)) { + // addClass(hintElement, fixedHintClassName); + // } - const hintDot = createElement("div", { - className: hintDotClassName, - }); + // const hintDot = createElement("div", { + // className: hintDotClassName, + // }); - const hintPulse = createElement("div", { - className: hintPulseClassName, - }); + // const hintPulse = createElement("div", { + // className: hintPulseClassName, + // }); - hintElement.appendChild(hintDot); - hintElement.appendChild(hintPulse); - hintElement.setAttribute(dataStepAttribute, i.toString()); + // hintElement.appendChild(hintDot); + // hintElement.appendChild(hintPulse); + // hintElement.setAttribute(dataStepAttribute, i.toString()); - // we swap the hint element with target element - // because _setHelperLayerPosition uses `element` property - hintItem.hintTargetElement = hintItem.element as HTMLElement; - hintItem.element = hintElement; + // // we swap the hint element with target element + // // because _setHelperLayerPosition uses `element` property + // hintItem.hintTargetElement = hintItem.element as HTMLElement; + // hintItem.element = hintElement; - // align the hint position - alignHintPosition( - hintItem.hintPosition as HintPosition, - hintElement, - hintItem.hintTargetElement as HTMLElement - ); + // // align the hint position + // alignHintPosition( + // hintItem.hintPosition as HintPosition, + // hintElement, + // hintItem.hintTargetElement as HTMLElement + // ); - hintsWrapper.appendChild(hintElement); - } + // hintsWrapper.appendChild(hintElement); + //} + + //HintsRoot({ hint }); // adding the hints wrapper - document.body.appendChild(hintsWrapper); + //document.body.appendChild(HintsRoot({ hint })); // call the callback function (if any) hint.callback("hintsAdded")?.call(hint); diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index b34050cab..f67c1c62e 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -12,7 +12,7 @@ import { hideHint } from "./hide"; import { setPositionRelativeTo } from "../../util/positionRelativeTo"; import DOMEvent from "../../util/DOMEvent"; import getOffset from "../../util/getOffset"; -import { HintTooltip } from "./hintTooltip"; +import { HintTooltip } from "./components/HintTooltip"; import van from "../dom/van"; // The hint close function used when the user clicks outside the hint @@ -52,6 +52,8 @@ export async function showHintDialog(hint: Hint, stepId: number) { if (!hintElement || !item) return; + hint._activeHintSignal.val = stepId; + // call the callback function (if any) await hint.callback("hintClick")?.call(hint, hintElement, item, stepId); @@ -83,62 +85,62 @@ export async function showHintDialog(hint: Hint, stepId: number) { //tooltipLayer.appendChild(tooltipTextLayer); - const step = hintElement.getAttribute(dataStepAttribute) || ""; - - // set current step for _placeTooltip function - const hintItem = hint.getHint(parseInt(step, 10)); - - if (!hintItem) return; - - const tooltipLayer = HintTooltip({ - position: van.state(hintItem.position), - text: item.hint || "", - targetOffset: van.state(getOffset(hintItem.element as HTMLElement)), - // hints don't have step numbers - showStepNumbers: false, - - autoPosition: hint.getOption("autoPosition"), - positionPrecedence: hint.getOption("positionPrecedence"), - - closeButtonEnabled: hint.getOption("hintShowButton"), - closeButtonLabel: hint.getOption("hintButtonLabel"), - closeButtonClassName: hint.getOption("buttonClass"), - closeButtonOnClick: () => hideHint(hint, stepId), - }); - - //const tooltipTextLayer = createElement("div"); - //const arrowLayer = createElement("div"); - const referenceLayer = createElement("div"); - - tooltipLayer.onclick = (e: Event) => { - //IE9 & Other Browsers - if (e.stopPropagation) { - e.stopPropagation(); - } - //IE8 and Lower - else { - e.cancelBubble = true; - } - }; + //const step = hintElement.getAttribute(dataStepAttribute) || ""; - // align reference layer position - setClass( - referenceLayer, - tooltipReferenceLayerClassName, - hintReferenceClassName - ); - referenceLayer.setAttribute(dataStepAttribute, step); - - const helperLayerPadding = hint.getOption("helperElementPadding"); - setPositionRelativeTo( - hint.getTargetElement(), - referenceLayer, - hintItem.element as HTMLElement, - helperLayerPadding - ); + //// set current step for _placeTooltip function + //const hintItem = hint.getHint(parseInt(step, 10)); + + //if (!hintItem) return; + + //const tooltipLayer = HintTooltip({ + // position: van.state(hintItem.position), + // text: item.hint || "", + // targetOffset: van.state(getOffset(hintItem.element as HTMLElement)), + // // hints don't have step numbers + // showStepNumbers: false, + + // autoPosition: hint.getOption("autoPosition"), + // positionPrecedence: hint.getOption("positionPrecedence"), + + // closeButtonEnabled: hint.getOption("hintShowButton"), + // closeButtonLabel: hint.getOption("hintButtonLabel"), + // closeButtonClassName: hint.getOption("buttonClass"), + // closeButtonOnClick: () => hideHint(hint, stepId), + //}); + + ////const tooltipTextLayer = createElement("div"); + ////const arrowLayer = createElement("div"); + //const referenceLayer = createElement("div"); + + //tooltipLayer.onclick = (e: Event) => { + // //IE9 & Other Browsers + // if (e.stopPropagation) { + // e.stopPropagation(); + // } + // //IE8 and Lower + // else { + // e.cancelBubble = true; + // } + //}; + + //// align reference layer position + //setClass( + // referenceLayer, + // tooltipReferenceLayerClassName, + // hintReferenceClassName + //); + //referenceLayer.setAttribute(dataStepAttribute, step); + + //const helperLayerPadding = hint.getOption("helperElementPadding"); + //setPositionRelativeTo( + // hint.getTargetElement(), + // referenceLayer, + // hintItem.element as HTMLElement, + // helperLayerPadding + //); - referenceLayer.appendChild(tooltipLayer); - document.body.appendChild(referenceLayer); + //referenceLayer.appendChild(tooltipLayer); + //document.body.appendChild(referenceLayer); // set proper position //placeTooltip( @@ -154,7 +156,8 @@ export async function showHintDialog(hint: Hint, stepId: number) { //); _hintCloseFunction = () => { - removeHintTooltip(); + //removeHintTooltip(); + hint._activeHintSignal.val = undefined; DOMEvent.off(document, "click", _hintCloseFunction, false); }; From a4bf0054df5babc07db20dac0341a00a035c2d7c Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 20:11:45 +0100 Subject: [PATCH 35/59] hint position --- src/packages/hint/components/HintIcon.ts | 7 +++- src/packages/hint/components/HintTooltip.ts | 17 ++++++--- src/packages/hint/components/HintsRoot.ts | 25 +++++++------ .../hint/components/ReferenceLayer.ts | 8 +++- src/packages/hint/hide.ts | 24 ++++-------- src/packages/hint/hint.ts | 37 ++++++++++++++----- src/packages/hint/hintItem.ts | 6 ++- src/packages/hint/position.ts | 4 +- src/packages/hint/render.ts | 18 --------- src/packages/hint/show.ts | 25 ++++--------- src/packages/hint/tooltip.ts | 16 +++----- 11 files changed, 94 insertions(+), 93 deletions(-) diff --git a/src/packages/hint/components/HintIcon.ts b/src/packages/hint/components/HintIcon.ts index 0e0ba7470..30bfcb501 100644 --- a/src/packages/hint/components/HintIcon.ts +++ b/src/packages/hint/components/HintIcon.ts @@ -2,6 +2,7 @@ import isFixed from "../../../util/isFixed"; import van from "../../dom/van"; import { fixedHintClassName, + hideHintClassName, hintClassName, hintDotClassName, hintNoAnimationClassName, @@ -30,6 +31,10 @@ const className = (hintItem: HintItem) => { classNames.push(fixedHintClassName); } + if (!hintItem.isActive?.val) { + classNames.push(hideHintClassName); + } + return classNames.join(" "); }; @@ -40,7 +45,7 @@ export const HintIcon = ({ index, hintItem, onClick }: HintProps) => { const hintElement = a( { [dataStepAttribute]: index.toString(), - className: className(hintItem), + className: () => className(hintItem), role: "button", tabindex: 0, onclick: onClick, diff --git a/src/packages/hint/components/HintTooltip.ts b/src/packages/hint/components/HintTooltip.ts index c07b6e330..2c3d191bb 100644 --- a/src/packages/hint/components/HintTooltip.ts +++ b/src/packages/hint/components/HintTooltip.ts @@ -5,16 +5,19 @@ import { HintItem } from "../hintItem"; const { a, p, div } = van.tags; -export type HintTooltipProps = Omit & { - text: string; +export type HintTooltipProps = Omit< + TooltipProps, + "hintMode" | "element" | "position" +> & { + hintItem: HintItem; closeButtonEnabled: boolean; - closeButtonOnClick: () => void; + closeButtonOnClick: (hintItem: HintItem) => void; closeButtonLabel: string; closeButtonClassName: string; }; export const HintTooltip = ({ - text, + hintItem, closeButtonEnabled, closeButtonOnClick, closeButtonLabel, @@ -24,18 +27,20 @@ export const HintTooltip = ({ return Tooltip( { ...props, + element: hintItem.hintTooltipElement as HTMLElement, + position: hintItem.position, hintMode: true, }, [ div( { className: tooltipTextClassName }, - p(text), + p(hintItem.hint || ""), closeButtonEnabled ? a( { className: closeButtonClassName, role: "button", - onclick: closeButtonOnClick, + onclick: () => closeButtonOnClick(hintItem), }, closeButtonLabel ) diff --git a/src/packages/hint/components/HintsRoot.ts b/src/packages/hint/components/HintsRoot.ts index aac3f4886..8733f9303 100644 --- a/src/packages/hint/components/HintsRoot.ts +++ b/src/packages/hint/components/HintsRoot.ts @@ -2,6 +2,7 @@ import van from "../../dom/van"; import { hintsClassName } from "../className"; import { hideHint } from "../hide"; import { Hint } from "../hint"; +import { HintItem } from "../hintItem"; import { showHintDialog } from "../tooltip"; import { HintIcon } from "./HintIcon"; import { ReferenceLayer } from "./ReferenceLayer"; @@ -33,13 +34,17 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { const hintElements = []; for (const [i, hintItem] of hint.getHints().entries()) { - hintElements.push( - HintIcon({ - index: i, - hintItem, - onClick: getHintClick(hint, i), - }) - ); + const hintTooltipElement = HintIcon({ + index: i, + hintItem, + onClick: getHintClick(hint, i), + }); + + // store the hint tooltip element in the hint item + // because we need to position the reference layer relative to the HintIcon + hintItem.hintTooltipElement = hintTooltipElement; + + hintElements.push(hintTooltipElement); } const root = div( @@ -58,9 +63,7 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { const referenceLayer = ReferenceLayer({ activeHintSignal: hint._activeHintSignal, - text: hintItem.hint || "", - element: hintItem.element as HTMLElement, - position: hintItem.position, + hintItem, helperElementPadding: hint.getOption("helperElementPadding"), targetElement: hint.getTargetElement(), @@ -76,7 +79,7 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { closeButtonEnabled: hint.getOption("hintShowButton"), closeButtonLabel: hint.getOption("hintButtonLabel"), closeButtonClassName: hint.getOption("buttonClass"), - closeButtonOnClick: () => hideHint(hint, stepId), + closeButtonOnClick: (hintItem: HintItem) => hideHint(hint, hintItem), }); van.add(root, referenceLayer); diff --git a/src/packages/hint/components/ReferenceLayer.ts b/src/packages/hint/components/ReferenceLayer.ts index 3726e6355..2d6194701 100644 --- a/src/packages/hint/components/ReferenceLayer.ts +++ b/src/packages/hint/components/ReferenceLayer.ts @@ -21,11 +21,17 @@ export const ReferenceLayer = ({ helperElementPadding, ...props }: ReferenceLayerProps) => { + const initialActiveHintSignal = activeHintSignal.val; + return () => { // remove the reference layer if the active hint signal is set to undefined // e.g. when the user clicks outside the hint if (activeHintSignal.val == undefined) return null; + // remove the reference layer if the active hint signal changes + // and the initial active hint signal is not same as the current active hint signal (e.g. when the user clicks on another hint) + if (initialActiveHintSignal !== activeHintSignal.val) return null; + const referenceLayer = div( { [dataStepAttribute]: activeHintSignal.val, @@ -38,7 +44,7 @@ export const ReferenceLayer = ({ setPositionRelativeTo( targetElement, referenceLayer, - props.element as HTMLElement, + props.hintItem.hintTooltipElement as HTMLElement, helperElementPadding ); }, 1); diff --git a/src/packages/hint/hide.ts b/src/packages/hint/hide.ts index d7b183434..100b3c791 100644 --- a/src/packages/hint/hide.ts +++ b/src/packages/hint/hide.ts @@ -4,23 +4,21 @@ import { addClass } from "../../util/className"; import { removeHintTooltip } from "./tooltip"; import { dataStepAttribute } from "./dataAttributes"; import { hintElement, hintElements } from "./selector"; +import { HintItem } from "./hintItem"; /** * Hide a hint * * @api private */ -export async function hideHint(hint: Hint, stepId: number) { - const element = hintElement(stepId); +export async function hideHint(hint: Hint, hintItem: HintItem) { + const isActiveSignal = hintItem.isActive; - //removeHintTooltip(); - - if (element) { - addClass(element, hideHintClassName); + if (isActiveSignal) { + isActiveSignal.val = false; } - // call the callback function (if any) - hint.callback("hintClose")?.call(hint, stepId); + hint.callback("hintClose")?.call(hint, hintItem); } /** @@ -29,13 +27,7 @@ export async function hideHint(hint: Hint, stepId: number) { * @api private */ export async function hideHints(hint: Hint) { - const elements = hintElements(); - - for (const hintElement of Array.from(elements)) { - const step = hintElement.getAttribute(dataStepAttribute); - - if (!step) continue; - - await hideHint(hint, parseInt(step, 10)); + for (const hintItem of hint.getHints()) { + await hideHint(hint, hintItem); } } diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index df35aa6e7..b700ef112 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -16,15 +16,11 @@ import van from "../dom/van"; import { HintsRoot } from "./components/HintsRoot"; type hintsAddedCallback = (this: Hint) => void | Promise; -type hintClickCallback = ( - this: Hint, - hintElement: HTMLElement, - item: HintItem, - stepId: number -) => void | Promise; -type hintCloseCallback = (this: Hint, stepId: number) => void | Promise; +type hintClickCallback = (this: Hint, item: HintItem) => void | Promise; +type hintCloseCallback = (this: Hint, item: HintItem) => void | Promise; export class Hint implements Package { + private _root: HTMLElement | undefined; private _hints: HintItem[] = []; private readonly _targetElement: HTMLElement; private _options: HintOptions; @@ -105,12 +101,19 @@ export class Hint implements Package { * @param hint The Hint item */ addHint(hint: HintItem): this { + // always set isActive to true + hint.isActive = van.state(true); this._hints.push(hint); return this; } + public isRendered() { + return this._root !== undefined; + } + private createRoot() { - van.add(this._targetElement, HintsRoot({ hint: this })); + this._root = HintsRoot({ hint: this }); + van.add(this._targetElement, this._root); } /** @@ -139,7 +142,12 @@ export class Hint implements Package { * @param stepId The hint step ID */ async hideHint(stepId: number) { - await hideHint(this, stepId); + const hintItem = this.getHint(stepId); + + if (hintItem) { + await hideHint(this, hintItem); + } + return this; } @@ -156,7 +164,12 @@ export class Hint implements Package { * @param stepId The hint step ID */ showHint(stepId: number) { - showHint(stepId); + const hintItem = this.getHint(stepId); + + if (hintItem) { + showHint(hintItem); + } + return this; } @@ -173,6 +186,10 @@ export class Hint implements Package { * Useful when you want to destroy the elements and add them again (e.g. a modal or popup) */ destroy() { + if (this._root) { + this._root.remove(); + this._root = undefined; + } removeHints(this); return this; } diff --git a/src/packages/hint/hintItem.ts b/src/packages/hint/hintItem.ts index aa5802ed1..820de9aff 100644 --- a/src/packages/hint/hintItem.ts +++ b/src/packages/hint/hintItem.ts @@ -7,6 +7,7 @@ import { dataHintPositionAttribute, dataTooltipClassAttribute, } from "./dataAttributes"; +import { State } from "../dom/van"; export type HintPosition = | "top-left" @@ -24,9 +25,12 @@ export type HintItem = { tooltipClass?: string; position: TooltipPosition; hint?: string; - hintTargetElement?: HTMLElement; + // this is the HintIcon element for this particular hint + // used for positioning the HintTooltip + hintTooltipElement?: HTMLElement; hintAnimation?: boolean; hintPosition: HintPosition; + isActive?: State; }; export const fetchHintItems = (hint: Hint) => { diff --git a/src/packages/hint/position.ts b/src/packages/hint/position.ts index 6ef6f471a..308f5f474 100644 --- a/src/packages/hint/position.ts +++ b/src/packages/hint/position.ts @@ -81,7 +81,7 @@ export const alignHintPosition = ( * @api private */ export function reAlignHints(hint: Hint) { - for (const { hintTargetElement, hintPosition, element } of hint.getHints()) { - alignHintPosition(hintPosition, element as HTMLElement, hintTargetElement); + for (const { hintTooltipElement, hintPosition, element } of hint.getHints()) { + alignHintPosition(hintPosition, element as HTMLElement, hintTooltipElement); } } diff --git a/src/packages/hint/render.ts b/src/packages/hint/render.ts index e5da1e301..7140e09bd 100644 --- a/src/packages/hint/render.ts +++ b/src/packages/hint/render.ts @@ -15,26 +15,8 @@ import setAnchorAsButton from "../../util/setAnchorAsButton"; import { addClass } from "../../util/className"; import isFixed from "../../util/isFixed"; import { alignHintPosition } from "./position"; -import { showHintDialog } from "./tooltip"; import { HintsRoot } from "./components/HintsRoot"; -/** - * Returns an event handler unique to the hint iteration - */ -const getHintClick = (hint: Hint, i: number) => (e: Event) => { - const evt = e ? e : window.event; - - if (evt && evt.stopPropagation) { - evt.stopPropagation(); - } - - if (evt && evt.cancelBubble !== null) { - evt.cancelBubble = true; - } - - showHintDialog(hint, i); -}; - /** * Add all available hints to the page * diff --git a/src/packages/hint/show.ts b/src/packages/hint/show.ts index 80db616b3..d80fb66dc 100644 --- a/src/packages/hint/show.ts +++ b/src/packages/hint/show.ts @@ -1,8 +1,5 @@ import { Hint } from "./hint"; -import { hideHintClassName } from "./className"; -import { dataStepAttribute } from "./dataAttributes"; -import { removeClass } from "../../util/className"; -import { hintElement, hintElements } from "./selector"; +import { HintItem } from "./hintItem"; /** * Show all hints @@ -10,15 +7,9 @@ import { hintElement, hintElements } from "./selector"; * @api private */ export async function showHints(hint: Hint) { - const elements = hintElements(); - - if (elements?.length) { - for (const hintElement of Array.from(elements)) { - const step = hintElement.getAttribute(dataStepAttribute); - - if (!step) continue; - - showHint(parseInt(step, 10)); + if (hint.isRendered()) { + for (const hintItem of hint.getHints()) { + showHint(hintItem); } } else { // or render hints if there are none @@ -31,10 +22,10 @@ export async function showHints(hint: Hint) { * * @api private */ -export function showHint(stepId: number) { - const element = hintElement(stepId); +export function showHint(hintItem: HintItem) { + const activeSignal = hintItem.isActive; - if (element) { - removeClass(element, new RegExp(hideHintClassName, "g")); + if (activeSignal) { + activeSignal.val = true; } } diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index f67c1c62e..a56730a59 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -44,26 +44,22 @@ export function removeHintTooltip(): string | undefined { * @api private */ export async function showHintDialog(hint: Hint, stepId: number) { - const hintElement = queryElement( - `.${hintClassName}[${dataStepAttribute}="${stepId}"]` - ); - const item = hint.getHint(stepId); - if (!hintElement || !item) return; + if (!item) return; hint._activeHintSignal.val = stepId; // call the callback function (if any) - await hint.callback("hintClick")?.call(hint, hintElement, item, stepId); + await hint.callback("hintClick")?.call(hint, item); // remove all open tooltips - const removedStep = removeHintTooltip(); + // const removedStep = removeHintTooltip(); // to toggle the tooltip - if (removedStep !== undefined && parseInt(removedStep, 10) === stepId) { - return; - } + //if (removedStep !== undefined && parseInt(removedStep, 10) === stepId) { + // return; + //} //setClass(tooltipTextLayer, tooltipTextClassName); From dddc71131f155a3274f48507de6591a259bbbd8b Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 20:32:58 +0100 Subject: [PATCH 36/59] fix hint onclicks --- src/packages/hint/components/HintTooltip.ts | 10 ++ src/packages/hint/hide.ts | 5 - src/packages/hint/hint.ts | 19 +++ src/packages/hint/tooltip.ts | 144 -------------------- src/packages/tooltip/tooltip.ts | 5 + 5 files changed, 34 insertions(+), 149 deletions(-) diff --git a/src/packages/hint/components/HintTooltip.ts b/src/packages/hint/components/HintTooltip.ts index 2c3d191bb..76838776a 100644 --- a/src/packages/hint/components/HintTooltip.ts +++ b/src/packages/hint/components/HintTooltip.ts @@ -30,6 +30,16 @@ export const HintTooltip = ({ element: hintItem.hintTooltipElement as HTMLElement, position: hintItem.position, hintMode: true, + onClick: (e: Event) => { + //IE9 & Other Browsers + if (e.stopPropagation) { + e.stopPropagation(); + } + //IE8 and Lower + else { + e.cancelBubble = true; + } + }, }, [ div( diff --git a/src/packages/hint/hide.ts b/src/packages/hint/hide.ts index 100b3c791..e64d53b13 100644 --- a/src/packages/hint/hide.ts +++ b/src/packages/hint/hide.ts @@ -1,9 +1,4 @@ import { Hint } from "./hint"; -import { hideHintClassName } from "./className"; -import { addClass } from "../../util/className"; -import { removeHintTooltip } from "./tooltip"; -import { dataStepAttribute } from "./dataAttributes"; -import { hintElement, hintElements } from "./selector"; import { HintItem } from "./hintItem"; /** diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index b700ef112..02a771dd0 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -27,6 +27,9 @@ export class Hint implements Package { public _activeHintSignal = van.state(undefined); public _refreshes = van.state(0); + // The hint close function used when the user clicks outside the hint + private _windowClickHandler?: () => void; + private readonly callbacks: { hintsAdded?: hintsAddedCallback; hintClick?: hintClickCallback; @@ -124,9 +127,20 @@ export class Hint implements Package { return this; } + if (this.isRendered()) { + return this; + } + fetchHintItems(this); await renderHints(this); this.createRoot(); + + this._windowClickHandler = () => { + this._activeHintSignal.val = undefined; + }; + + DOMEvent.on(document, "click", this._windowClickHandler, false); + return this; } @@ -190,6 +204,11 @@ export class Hint implements Package { this._root.remove(); this._root = undefined; } + + if (this._windowClickHandler) { + DOMEvent.off(document, "click", this._windowClickHandler, false); + } + removeHints(this); return this; } diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts index a56730a59..a9ce1d27c 100644 --- a/src/packages/hint/tooltip.ts +++ b/src/packages/hint/tooltip.ts @@ -1,42 +1,4 @@ -import { queryElement, queryElementByClassName } from "../../util/queryElement"; -import { - hintClassName, - hintReferenceClassName, - tooltipReferenceLayerClassName, -} from "./className"; -import { dataStepAttribute } from "./dataAttributes"; import { Hint } from "./hint"; -import createElement from "../../util/createElement"; -import { setClass } from "../../util/className"; -import { hideHint } from "./hide"; -import { setPositionRelativeTo } from "../../util/positionRelativeTo"; -import DOMEvent from "../../util/DOMEvent"; -import getOffset from "../../util/getOffset"; -import { HintTooltip } from "./components/HintTooltip"; -import van from "../dom/van"; - -// The hint close function used when the user clicks outside the hint -let _hintCloseFunction: () => void | undefined; - -/** - * Removes open hint (tooltip hint) - * - * @api private - */ -export function removeHintTooltip(): string | undefined { - const tooltip = queryElementByClassName(hintReferenceClassName); - - if (tooltip && tooltip.parentNode) { - const step = tooltip.getAttribute(dataStepAttribute); - if (!step) return undefined; - - tooltip.parentNode.removeChild(tooltip); - - return step; - } - - return undefined; -} /** * Triggers when user clicks on the hint element @@ -52,110 +14,4 @@ export async function showHintDialog(hint: Hint, stepId: number) { // call the callback function (if any) await hint.callback("hintClick")?.call(hint, item); - - // remove all open tooltips - // const removedStep = removeHintTooltip(); - - // to toggle the tooltip - //if (removedStep !== undefined && parseInt(removedStep, 10) === stepId) { - // return; - //} - - //setClass(tooltipTextLayer, tooltipTextClassName); - - //const tooltipWrapper = createElement("p"); - //tooltipWrapper.innerHTML = item.hint || ""; - //tooltipTextLayer.appendChild(tooltipWrapper); - - //if (hint.getOption("hintShowButton")) { - // const closeButton = createElement("a"); - // closeButton.className = hint.getOption("buttonClass"); - // closeButton.setAttribute("role", "button"); - // closeButton.innerHTML = hint.getOption("hintButtonLabel"); - // closeButton.onclick = () => hideHint(hint, stepId); - // tooltipTextLayer.appendChild(closeButton); - //} - - //setClass(arrowLayer, arrowClassName); - //tooltipLayer.appendChild(arrowLayer); - - //tooltipLayer.appendChild(tooltipTextLayer); - - //const step = hintElement.getAttribute(dataStepAttribute) || ""; - - //// set current step for _placeTooltip function - //const hintItem = hint.getHint(parseInt(step, 10)); - - //if (!hintItem) return; - - //const tooltipLayer = HintTooltip({ - // position: van.state(hintItem.position), - // text: item.hint || "", - // targetOffset: van.state(getOffset(hintItem.element as HTMLElement)), - // // hints don't have step numbers - // showStepNumbers: false, - - // autoPosition: hint.getOption("autoPosition"), - // positionPrecedence: hint.getOption("positionPrecedence"), - - // closeButtonEnabled: hint.getOption("hintShowButton"), - // closeButtonLabel: hint.getOption("hintButtonLabel"), - // closeButtonClassName: hint.getOption("buttonClass"), - // closeButtonOnClick: () => hideHint(hint, stepId), - //}); - - ////const tooltipTextLayer = createElement("div"); - ////const arrowLayer = createElement("div"); - //const referenceLayer = createElement("div"); - - //tooltipLayer.onclick = (e: Event) => { - // //IE9 & Other Browsers - // if (e.stopPropagation) { - // e.stopPropagation(); - // } - // //IE8 and Lower - // else { - // e.cancelBubble = true; - // } - //}; - - //// align reference layer position - //setClass( - // referenceLayer, - // tooltipReferenceLayerClassName, - // hintReferenceClassName - //); - //referenceLayer.setAttribute(dataStepAttribute, step); - - //const helperLayerPadding = hint.getOption("helperElementPadding"); - //setPositionRelativeTo( - // hint.getTargetElement(), - // referenceLayer, - // hintItem.element as HTMLElement, - // helperLayerPadding - //); - - //referenceLayer.appendChild(tooltipLayer); - //document.body.appendChild(referenceLayer); - - // set proper position - //placeTooltip( - // tooltipLayer, - // arrowLayer, - // hintItem.element as HTMLElement, - // hintItem.position, - // hint.getOption("positionPrecedence"), - // // hints don't have step numbers - // false, - // hint.getOption("autoPosition"), - // hintItem.tooltipClass ?? hint.getOption("tooltipClass") - //); - - _hintCloseFunction = () => { - //removeHintTooltip(); - hint._activeHintSignal.val = undefined; - DOMEvent.off(document, "click", _hintCloseFunction, false); - }; - - DOMEvent.on(document, "click", _hintCloseFunction, false); } diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index 51b1fe68c..ead1dd798 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -314,6 +314,8 @@ export type TooltipProps = { // auto-alignment properties autoPosition: boolean; positionPrecedence: TooltipPosition[]; + + onClick?: (e: any) => void; }; export const Tooltip = ( @@ -329,6 +331,8 @@ export const Tooltip = ( // auto-alignment properties autoPosition = true, positionPrecedence = [], + + onClick, }: TooltipProps, children?: ChildDom[] ) => { @@ -418,6 +422,7 @@ export const Tooltip = ( `top: ${top.val}; right: ${right.val}; bottom: ${bottom.val}; left: ${left.val}; margin-left: ${marginLeft.val}; margin-top: ${marginTop.val};opacity: ${opacity.val}`, className: () => `${tooltipClassName} introjs-${position.val}`, role: "dialog", + onclick: onClick ?? null, }, [ TooltipArrow({ From 83345c721165ec9a6dd03b6be887ee2b7e28dfb9 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 22:53:17 +0100 Subject: [PATCH 37/59] update hint API --- src/packages/hint/components/HintIcon.ts | 24 +++-- src/packages/hint/components/HintsRoot.ts | 15 +-- src/packages/hint/hide.ts | 3 + src/packages/hint/hint.ts | 111 ++++++++++++++++++---- src/packages/hint/position.ts | 12 --- src/packages/hint/remove.ts | 38 -------- src/packages/hint/render.ts | 95 ------------------ src/packages/hint/selector.ts | 17 ---- src/packages/hint/tooltip.ts | 17 ---- 9 files changed, 119 insertions(+), 213 deletions(-) delete mode 100644 src/packages/hint/remove.ts delete mode 100644 src/packages/hint/render.ts delete mode 100644 src/packages/hint/selector.ts delete mode 100644 src/packages/hint/tooltip.ts diff --git a/src/packages/hint/components/HintIcon.ts b/src/packages/hint/components/HintIcon.ts index 30bfcb501..fbcd00416 100644 --- a/src/packages/hint/components/HintIcon.ts +++ b/src/packages/hint/components/HintIcon.ts @@ -1,5 +1,5 @@ import isFixed from "../../../util/isFixed"; -import van from "../../dom/van"; +import van, { State } from "../../dom/van"; import { fixedHintClassName, hideHintClassName, @@ -17,6 +17,7 @@ const { a, div } = van.tags; export type HintProps = { index: number; hintItem: HintItem; + refreshesSignal: State; onClick: (e: any) => void; }; @@ -41,7 +42,12 @@ const className = (hintItem: HintItem) => { const HintDot = () => div({ className: hintDotClassName }); const HintPulse = () => div({ className: hintPulseClassName }); -export const HintIcon = ({ index, hintItem, onClick }: HintProps) => { +export const HintIcon = ({ + index, + hintItem, + onClick, + refreshesSignal, +}: HintProps) => { const hintElement = a( { [dataStepAttribute]: index.toString(), @@ -54,11 +60,15 @@ export const HintIcon = ({ index, hintItem, onClick }: HintProps) => { HintPulse() ); - alignHintPosition( - hintItem.hintPosition as HintPosition, - hintElement, - hintItem.element as HTMLElement - ); + van.derive(() => { + if (refreshesSignal.val === undefined) return; + + alignHintPosition( + hintItem.hintPosition as HintPosition, + hintElement, + hintItem.element as HTMLElement + ); + }); return hintElement; }; diff --git a/src/packages/hint/components/HintsRoot.ts b/src/packages/hint/components/HintsRoot.ts index 8733f9303..1481a9f86 100644 --- a/src/packages/hint/components/HintsRoot.ts +++ b/src/packages/hint/components/HintsRoot.ts @@ -3,7 +3,6 @@ import { hintsClassName } from "../className"; import { hideHint } from "../hide"; import { Hint } from "../hint"; import { HintItem } from "../hintItem"; -import { showHintDialog } from "../tooltip"; import { HintIcon } from "./HintIcon"; import { ReferenceLayer } from "./ReferenceLayer"; @@ -27,7 +26,7 @@ const getHintClick = (hint: Hint, i: number) => (e: Event) => { evt.cancelBubble = true; } - showHintDialog(hint, i); + hint.showHintDialog(i); }; export const HintsRoot = ({ hint }: HintsRootProps) => { @@ -38,6 +37,7 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { index: i, hintItem, onClick: getHintClick(hint, i), + refreshesSignal: hint.getRefreshesSignal(), }); // store the hint tooltip element in the hint item @@ -55,20 +55,23 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { ); van.derive(() => { - if (hint._activeHintSignal.val === undefined) return; + const activeHintSignal = hint.getActiveHintSignal(); + if (activeHintSignal.val === undefined) return; - const stepId = hint._activeHintSignal.val; + const stepId = activeHintSignal.val; const hints = hint.getHints(); const hintItem = hints[stepId]; + if (!hintItem) return; + const referenceLayer = ReferenceLayer({ - activeHintSignal: hint._activeHintSignal, + activeHintSignal, hintItem, helperElementPadding: hint.getOption("helperElementPadding"), targetElement: hint.getTargetElement(), - refreshes: hint._refreshes, + refreshes: hint.getRefreshesSignal(), // hints don't have step numbers showStepNumbers: false, diff --git a/src/packages/hint/hide.ts b/src/packages/hint/hide.ts index e64d53b13..484b7cdb3 100644 --- a/src/packages/hint/hide.ts +++ b/src/packages/hint/hide.ts @@ -12,6 +12,9 @@ export async function hideHint(hint: Hint, hintItem: HintItem) { if (isActiveSignal) { isActiveSignal.val = false; } + + hint.hideHintDialog(); + // call the callback function (if any) hint.callback("hintClose")?.call(hint, hintItem); } diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index 02a771dd0..af7c503a6 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -4,14 +4,10 @@ import { fetchHintItems, HintItem } from "./hintItem"; import { setOption, setOptions } from "../../option"; import isFunction from "../../util/isFunction"; import debounce from "../../util/debounce"; -import { reAlignHints } from "./position"; import DOMEvent from "../../util/DOMEvent"; import { getContainerElement } from "../../util/containerElement"; -import { renderHints } from "./render"; import { hideHint, hideHints } from "./hide"; import { showHint, showHints } from "./show"; -import { removeHint, removeHints } from "./remove"; -import { showHintDialog } from "./tooltip"; import van from "../dom/van"; import { HintsRoot } from "./components/HintsRoot"; @@ -24,11 +20,8 @@ export class Hint implements Package { private _hints: HintItem[] = []; private readonly _targetElement: HTMLElement; private _options: HintOptions; - public _activeHintSignal = van.state(undefined); - public _refreshes = van.state(0); - - // The hint close function used when the user clicks outside the hint - private _windowClickHandler?: () => void; + private _activeHintSignal = van.state(undefined); + private _refreshes = van.state(0); private readonly callbacks: { hintsAdded?: hintsAddedCallback; @@ -38,6 +31,8 @@ export class Hint implements Package { // Event handlers private _hintsAutoRefreshFunction?: () => void; + // The hint close function used when the user clicks outside the hint + private _windowClickFunction?: () => void; /** * Create a new Hint instance @@ -110,7 +105,26 @@ export class Hint implements Package { return this; } - public isRendered() { + /** + * Get the active hint signal + * This is meant to be used internally by the Hint package + */ + getActiveHintSignal() { + return this._activeHintSignal; + } + + /** + * Returns the underlying state of the refreshes + * This is an internal method and should not be used outside of the package. + */ + getRefreshesSignal() { + return this._refreshes; + } + + /** + * Returns true if the hints are rendered + */ + isRendered() { return this._root !== undefined; } @@ -119,6 +133,13 @@ export class Hint implements Package { van.add(this._targetElement, this._root); } + private recreateRoot() { + if (this._root) { + this._root.remove(); + this.createRoot(); + } + } + /** * Render hints on the page */ @@ -132,16 +153,34 @@ export class Hint implements Package { } fetchHintItems(this); - await renderHints(this); this.createRoot(); - this._windowClickHandler = () => { + this.callback("hintsAdded")?.call(this); + + this.enableHintAutoRefresh(); + this.enableCloseDialogOnWindowClick(); + + return this; + } + + /** + * Enable closing the dialog when the user clicks outside the hint + */ + enableCloseDialogOnWindowClick() { + this._windowClickFunction = () => { this._activeHintSignal.val = undefined; }; - DOMEvent.on(document, "click", this._windowClickHandler, false); + DOMEvent.on(document, "click", this._windowClickFunction, false); + } - return this; + /** + * Disable closing the dialog when the user clicks outside the hint + */ + disableCloseDialogOnWindowClick() { + if (this._windowClickFunction) { + DOMEvent.off(document, "click", this._windowClickFunction, false); + } } /** @@ -205,11 +244,9 @@ export class Hint implements Package { this._root = undefined; } - if (this._windowClickHandler) { - DOMEvent.off(document, "click", this._windowClickHandler, false); - } + this.disableHintAutoRefresh(); + this.disableCloseDialogOnWindowClick(); - removeHints(this); return this; } @@ -229,7 +266,9 @@ export class Hint implements Package { * @param stepId The hint step ID */ removeHint(stepId: number) { - removeHint(stepId); + this._hints = this._hints.filter((_, i) => i !== stepId); + this.recreateRoot(); + return this; } @@ -238,8 +277,38 @@ export class Hint implements Package { * @param stepId The hint step ID */ async showHintDialog(stepId: number) { + const item = this.getHint(stepId); + + if (!item) return; + this._activeHintSignal.val = stepId; - await showHintDialog(this, stepId); + + // call the callback function (if any) + await this.callback("hintClick")?.call(this, item); + + return this; + } + + /** + * Hide hint dialog from the page + */ + hideHintDialog() { + this._activeHintSignal.val = undefined; + return this; + } + + /** + * Refresh the hints on the page + */ + public refresh() { + if (!this.isRendered()) { + return this; + } + + if (this._refreshes.val !== undefined) { + this._refreshes.val += 1; + } + return this; } @@ -250,7 +319,7 @@ export class Hint implements Package { const hintAutoRefreshInterval = this.getOption("hintAutoRefreshInterval"); if (hintAutoRefreshInterval >= 0) { this._hintsAutoRefreshFunction = debounce( - () => reAlignHints(this), + () => this.refresh(), hintAutoRefreshInterval ); diff --git a/src/packages/hint/position.ts b/src/packages/hint/position.ts index 308f5f474..d3430f404 100644 --- a/src/packages/hint/position.ts +++ b/src/packages/hint/position.ts @@ -1,6 +1,5 @@ import getOffset from "../../util/getOffset"; import { HintPosition } from "./hintItem"; -import { Hint } from "./hint"; /** * Aligns hint position @@ -74,14 +73,3 @@ export const alignHintPosition = ( break; } }; - -/** - * Re-aligns all hint elements - * - * @api private - */ -export function reAlignHints(hint: Hint) { - for (const { hintTooltipElement, hintPosition, element } of hint.getHints()) { - alignHintPosition(hintPosition, element as HTMLElement, hintTooltipElement); - } -} diff --git a/src/packages/hint/remove.ts b/src/packages/hint/remove.ts deleted file mode 100644 index 7b7d34dac..000000000 --- a/src/packages/hint/remove.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Hint } from "./hint"; -import { dataStepAttribute } from "./dataAttributes"; -import { hintElement, hintElements } from "./selector"; - -/** - * Removes all hint elements on the page - * Useful when you want to destroy the elements and add them again (e.g. a modal or popup) - * - * @api private - */ -export function removeHints(hint: Hint) { - const elements = hintElements(); - - for (const hintElement of Array.from(elements)) { - const step = hintElement.getAttribute(dataStepAttribute); - - if (step === null) continue; - - removeHint(parseInt(step, 10)); - } - - hint.disableHintAutoRefresh(); -} - -/** - * Remove one single hint element from the page - * Useful when you want to destroy the element and add them again (e.g. a modal or popup) - * Use removeHints if you want to remove all elements. - * - * @api private - */ -export function removeHint(stepId: number) { - const element = hintElement(stepId); - - if (element && element.parentNode) { - element.parentNode.removeChild(element); - } -} diff --git a/src/packages/hint/render.ts b/src/packages/hint/render.ts deleted file mode 100644 index 7140e09bd..000000000 --- a/src/packages/hint/render.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { queryElement, queryElementByClassName } from "../../util/queryElement"; -import { Hint } from "./hint"; -import { HintPosition } from "./hintItem"; -import { - fixedHintClassName, - hintClassName, - hintDotClassName, - hintNoAnimationClassName, - hintPulseClassName, - hintsClassName, -} from "./className"; -import createElement from "../../util/createElement"; -import { dataStepAttribute } from "./dataAttributes"; -import setAnchorAsButton from "../../util/setAnchorAsButton"; -import { addClass } from "../../util/className"; -import isFixed from "../../util/isFixed"; -import { alignHintPosition } from "./position"; -import { HintsRoot } from "./components/HintsRoot"; - -/** - * Add all available hints to the page - * - * @api private - */ -export async function renderHints(hint: Hint) { - let hintsWrapper = queryElementByClassName(hintsClassName); - - if (hintsWrapper === null) { - hintsWrapper = createElement("div", { - className: hintsClassName, - }); - } - - //const hints = hint.getHints(); - //for (let i = 0; i < hints.length; i++) { - // const hintItem = hints[i]; - - // // avoid append a hint twice - // if (queryElement(`.${hintClassName}[${dataStepAttribute}="${i}"]`)) { - // return; - // } - - // const hintElement = createElement("a", { - // className: hintClassName, - // }); - // setAnchorAsButton(hintElement); - - // hintElement.onclick = getHintClick(hint, i); - - // if (!hintItem.hintAnimation) { - // addClass(hintElement, hintNoAnimationClassName); - // } - - // // hint's position should be fixed if the target element's position is fixed - // if (isFixed(hintItem.element as HTMLElement)) { - // addClass(hintElement, fixedHintClassName); - // } - - // const hintDot = createElement("div", { - // className: hintDotClassName, - // }); - - // const hintPulse = createElement("div", { - // className: hintPulseClassName, - // }); - - // hintElement.appendChild(hintDot); - // hintElement.appendChild(hintPulse); - // hintElement.setAttribute(dataStepAttribute, i.toString()); - - // // we swap the hint element with target element - // // because _setHelperLayerPosition uses `element` property - // hintItem.hintTargetElement = hintItem.element as HTMLElement; - // hintItem.element = hintElement; - - // // align the hint position - // alignHintPosition( - // hintItem.hintPosition as HintPosition, - // hintElement, - // hintItem.hintTargetElement as HTMLElement - // ); - - // hintsWrapper.appendChild(hintElement); - //} - - //HintsRoot({ hint }); - - // adding the hints wrapper - //document.body.appendChild(HintsRoot({ hint })); - - // call the callback function (if any) - hint.callback("hintsAdded")?.call(hint); - - hint.enableHintAutoRefresh(); -} diff --git a/src/packages/hint/selector.ts b/src/packages/hint/selector.ts deleted file mode 100644 index 95a52b277..000000000 --- a/src/packages/hint/selector.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - queryElementByClassName, - queryElementsByClassName, -} from "../../util/queryElement"; -import { hintClassName, hintsClassName } from "./className"; -import { dataStepAttribute } from "./dataAttributes"; - -const hintsContainer = () => queryElementByClassName(hintsClassName); - -export const hintElements = () => - queryElementsByClassName(hintClassName, hintsContainer()); - -export const hintElement = (stepId: number) => - queryElementsByClassName( - `${hintClassName}[${dataStepAttribute}="${stepId}"]`, - hintsContainer() - )[0]; diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts deleted file mode 100644 index a9ce1d27c..000000000 --- a/src/packages/hint/tooltip.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Hint } from "./hint"; - -/** - * Triggers when user clicks on the hint element - * - * @api private - */ -export async function showHintDialog(hint: Hint, stepId: number) { - const item = hint.getHint(stepId); - - if (!item) return; - - hint._activeHintSignal.val = stepId; - - // call the callback function (if any) - await hint.callback("hintClick")?.call(hint, item); -} From 667d31445cccbb64b5fc280b338b3e7a4f637318 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 22:57:28 +0100 Subject: [PATCH 38/59] rename --- src/packages/hint/hint.ts | 8 ++++---- src/packages/tour/tour.ts | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index af7c503a6..3c0427640 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -21,7 +21,7 @@ export class Hint implements Package { private readonly _targetElement: HTMLElement; private _options: HintOptions; private _activeHintSignal = van.state(undefined); - private _refreshes = van.state(0); + private _refreshesSignal = van.state(0); private readonly callbacks: { hintsAdded?: hintsAddedCallback; @@ -118,7 +118,7 @@ export class Hint implements Package { * This is an internal method and should not be used outside of the package. */ getRefreshesSignal() { - return this._refreshes; + return this._refreshesSignal; } /** @@ -305,8 +305,8 @@ export class Hint implements Package { return this; } - if (this._refreshes.val !== undefined) { - this._refreshes.val += 1; + if (this._refreshesSignal.val !== undefined) { + this._refreshesSignal.val += 1; } return this; diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index 72ba003d3..cd7665927 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -28,8 +28,8 @@ import { FloatingElement } from "./components/FloatingElement"; */ export class Tour implements Package { private _steps: TourStep[] = []; - private _currentStep = van.state(undefined); - private _refreshes = van.state(0); + private _currentStepSignal = van.state(undefined); + private _refreshesSignal = van.state(0); private _root: Element | undefined; private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; @@ -170,7 +170,7 @@ export class Tour implements Package { * This is an internal method and should not be used outside of the package. */ getCurrentStepSignal() { - return this._currentStep; + return this._currentStepSignal; } /** @@ -178,25 +178,25 @@ export class Tour implements Package { * This is an internal method and should not be used outside of the package. */ getRefreshesSignal() { - return this._refreshes; + return this._refreshesSignal; } /** * Get the current step of the tour */ getCurrentStep(): number | undefined { - return this._currentStep.val; + return this._currentStepSignal.val; } /** * @deprecated `currentStep()` is deprecated, please use `getCurrentStep()` instead. */ currentStep(): number | undefined { - return this._currentStep.val; + return this._currentStepSignal.val; } resetCurrentStep() { - this._currentStep.val = undefined; + this._currentStepSignal.val = undefined; } /** @@ -204,13 +204,16 @@ export class Tour implements Package { * @param step */ setCurrentStep(step: number): this { - if (this._currentStep.val === undefined || step >= this._currentStep.val) { + if ( + this._currentStepSignal.val === undefined || + step >= this._currentStepSignal.val + ) { this._direction = "forward"; } else { this._direction = "backward"; } - this._currentStep.val = step; + this._currentStepSignal.val = step; return this; } @@ -471,8 +474,8 @@ export class Tour implements Package { return this; } - if (this._refreshes.val !== undefined) { - this._refreshes.val += 1; + if (this._refreshesSignal.val !== undefined) { + this._refreshesSignal.val += 1; } // fetch new steps and recreate the root element From 62af2053ce8eb53e287448c6a9bbe5ff1e9350ea Mon Sep 17 00:00:00 2001 From: binrysearch Date: Sun, 8 Sep 2024 23:04:21 +0100 Subject: [PATCH 39/59] remove unused --- src/util/appendChild.ts | 26 -------------------- src/util/checkLeft.ts | 35 -------------------------- src/util/checkRight.ts | 40 ------------------------------ src/util/createElement.test.ts | 45 ---------------------------------- src/util/createElement.ts | 31 ----------------------- src/util/queryElement.ts | 17 ------------- 6 files changed, 194 deletions(-) delete mode 100644 src/util/appendChild.ts delete mode 100644 src/util/checkLeft.ts delete mode 100644 src/util/checkRight.ts delete mode 100644 src/util/createElement.test.ts delete mode 100644 src/util/createElement.ts diff --git a/src/util/appendChild.ts b/src/util/appendChild.ts deleted file mode 100644 index 93c72e8a1..000000000 --- a/src/util/appendChild.ts +++ /dev/null @@ -1,26 +0,0 @@ -import setStyle from "./style"; - -/** - * Appends `element` to `parentElement` - */ -export default function appendChild( - parentElement: HTMLElement, - element: HTMLElement, - animate: boolean = false -) { - if (animate) { - const existingOpacity = element.style.opacity || "1"; - - setStyle(element, { - opacity: "0", - }); - - window.setTimeout(() => { - setStyle(element, { - opacity: existingOpacity, - }); - }, 10); - } - - parentElement.appendChild(element); -} diff --git a/src/util/checkLeft.ts b/src/util/checkLeft.ts deleted file mode 100644 index 4ad912f1e..000000000 --- a/src/util/checkLeft.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Set tooltip right so it doesn't go off the left side of the window - * - * @return boolean true, if tooltipLayerStyleRight is ok. false, otherwise. - */ -export default function checkLeft( - targetOffset: { - top: number; - left: number; - width: number; - height: number; - }, - tooltipLayerStyleRight: number, - tooltipOffset: { - top: number; - left: number; - width: number; - height: number; - }, - tooltipLayer: HTMLElement -): boolean { - if ( - targetOffset.left + - targetOffset.width - - tooltipLayerStyleRight - - tooltipOffset.width < - 0 - ) { - // off the left side of the window - tooltipLayer.style.left = `${-targetOffset.left}px`; - return false; - } - tooltipLayer.style.right = `${tooltipLayerStyleRight}px`; - return true; -} diff --git a/src/util/checkRight.ts b/src/util/checkRight.ts deleted file mode 100644 index 35d1de58e..000000000 --- a/src/util/checkRight.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Set tooltip left so it doesn't go off the right side of the window - * - * @return boolean true, if tooltipLayerStyleLeft is ok. false, otherwise. - */ -export default function checkRight( - targetOffset: { - top: number; - left: number; - width: number; - height: number; - }, - tooltipLayerStyleLeft: number, - tooltipOffset: { - top: number; - left: number; - width: number; - height: number; - }, - windowSize: { - width: number; - height: number; - }, - tooltipLayer: HTMLElement -): boolean { - if ( - targetOffset.left + tooltipLayerStyleLeft + tooltipOffset.width > - windowSize.width - ) { - // off the right side of the window - tooltipLayer.style.left = `${ - windowSize.width - tooltipOffset.width - targetOffset.left - }px`; - - return false; - } - - tooltipLayer.style.left = `${tooltipLayerStyleLeft}px`; - return true; -} diff --git a/src/util/createElement.test.ts b/src/util/createElement.test.ts deleted file mode 100644 index b03cd5d0e..000000000 --- a/src/util/createElement.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import createElement from "./createElement"; - -describe("createElement", () => { - test("should create an element", () => { - expect(createElement("div").tagName).toBe("DIV"); - expect(createElement("b").tagName).toBe("B"); - }); - - test("should create an element with properties", () => { - const el = createElement("div", { - className: "myClass", - }); - - expect(el.className).toBe("myClass"); - }); - - test("should create an element with data-* props", () => { - const el = createElement("div", { - "data-test-prop": "10", - }); - - expect(el.getAttribute("data-test-prop")).toBe("10"); - }); - - test("should create an element with correct style", () => { - const el = createElement("div", { - style: "background: red;font-size: 12px;", - }); - - expect(el.style.fontSize).toBe("12px"); - expect(el.style.backgroundColor).toBe("red"); - }); - - test("should create an element with onclick", () => { - const mock = jest.fn(); - const el = createElement("div", { - onclick: mock, - }); - - el.click(); - el.click(); - - expect(mock).toBeCalledTimes(2); - }); -}); diff --git a/src/util/createElement.ts b/src/util/createElement.ts deleted file mode 100644 index f27f662f0..000000000 --- a/src/util/createElement.ts +++ /dev/null @@ -1,31 +0,0 @@ -import setStyle from "./style"; - -/** - * Create a DOM element with various attributes - */ -export default function _createElement( - tagName: K, - attrs?: { [key: string]: string | Function } -): HTMLElementTagNameMap[K] { - let element = document.createElement(tagName); - - attrs = attrs || {}; - - // regex for matching attributes that need to be set with setAttribute - const setAttRegex = /^(?:role|data-|aria-)/; - - for (const k in attrs) { - let v = attrs[k]; - - if (k === "style" && typeof v !== "function") { - setStyle(element, v); - } else if (typeof v === "string" && k.match(setAttRegex)) { - element.setAttribute(k, v); - } else { - // @ts-ignore - element[k] = v; - } - } - - return element; -} diff --git a/src/util/queryElement.ts b/src/util/queryElement.ts index cdae51baa..1e50ec64d 100644 --- a/src/util/queryElement.ts +++ b/src/util/queryElement.ts @@ -12,13 +12,6 @@ export const queryElements = ( return (container ?? document).querySelectorAll(selector); }; -export const queryElementByClassName = ( - className: string, - container?: HTMLElement | null -): HTMLElement | null => { - return queryElement(`.${className}`, container); -}; - export const queryElementsByClassName = ( className: string, container?: HTMLElement | null @@ -26,16 +19,6 @@ export const queryElementsByClassName = ( return queryElements(`.${className}`, container); }; -export const getElementByClassName = ( - className: string, - container?: HTMLElement | null -): HTMLElement => { - const element = queryElementByClassName(className, container); - if (!element) { - throw new Error(`Element with class name ${className} not found`); - } - return element; -}; export const getElement = ( selector: string, From d68ed0a6f7496567f4e1726c9f1f34efe4a671df Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 07:29:43 +0100 Subject: [PATCH 40/59] util unit tests --- src/util/elementInViewport.test.ts | 14 ++++++++------ src/util/positionRelativeTo.test.ts | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/util/elementInViewport.test.ts b/src/util/elementInViewport.test.ts index b2d26a406..bbca28884 100644 --- a/src/util/elementInViewport.test.ts +++ b/src/util/elementInViewport.test.ts @@ -1,6 +1,8 @@ -import _createElement from "./createElement"; +import van from "../packages/dom/van"; import elementInViewport from "./elementInViewport"; +const {div} = van.tags; + describe("elementInViewport", () => { test("should return true when element is in viewport", () => { // Arrange @@ -13,7 +15,7 @@ describe("elementInViewport", () => { right: 50, } as DOMRect) ); - const elm = _createElement("div"); + const elm = div(); elm.getBoundingClientRect = getBoundingClientRectSpy; // Act @@ -34,7 +36,7 @@ describe("elementInViewport", () => { right: 50, } as DOMRect) ); - const elm = _createElement("div"); + const elm = div(); elm.getBoundingClientRect = getBoundingClientRectSpy; // Act @@ -55,7 +57,7 @@ describe("elementInViewport", () => { right: window.innerWidth + 50, } as DOMRect) ); - const elm = _createElement("div"); + const elm = div(); elm.getBoundingClientRect = getBoundingClientRectSpy; // Act @@ -76,7 +78,7 @@ describe("elementInViewport", () => { right: 50, } as DOMRect) ); - const elm = _createElement("div"); + const elm = div(); elm.getBoundingClientRect = getBoundingClientRectSpy; // Act @@ -97,7 +99,7 @@ describe("elementInViewport", () => { right: 50, } as DOMRect) ); - const elm = _createElement("div"); + const elm = div(); elm.getBoundingClientRect = getBoundingClientRectSpy; // Act diff --git a/src/util/positionRelativeTo.test.ts b/src/util/positionRelativeTo.test.ts index 101e0c02a..06e896ee7 100644 --- a/src/util/positionRelativeTo.test.ts +++ b/src/util/positionRelativeTo.test.ts @@ -1,6 +1,8 @@ import { setPositionRelativeTo } from "./positionRelativeTo"; -import createElement from "./createElement"; import { getBoundingClientRectSpy } from "../../tests/jest/helper"; +import van from "../packages/dom/van" + +const {div} = van.tags; describe("setPositionRelativeTo", () => { it("should return if helperLayer or currentStep is null", () => { @@ -18,7 +20,7 @@ describe("setPositionRelativeTo", () => { it("should set the correct width, height, top, left", () => { // Arrange - const stepElement = createElement("div"); + const stepElement = div(); stepElement.getBoundingClientRect = getBoundingClientRectSpy( 200, 100, @@ -28,7 +30,7 @@ describe("setPositionRelativeTo", () => { 100 ); - const helperLayer = createElement("div"); + const helperLayer = div(); helperLayer.getBoundingClientRect = getBoundingClientRectSpy( 500, 500, @@ -50,12 +52,12 @@ describe("setPositionRelativeTo", () => { it("should add fixedTooltip if element is fixed", () => { // Arrange - const stepElementParent = createElement("div"); - const stepElement = createElement("div"); + const stepElementParent = div(); + const stepElement = div(); stepElement.style.position = "fixed"; stepElementParent.appendChild(stepElement); - const helperLayer = createElement("div"); + const helperLayer = div(); // Act setPositionRelativeTo(stepElementParent, helperLayer, stepElement, 10); @@ -66,10 +68,10 @@ describe("setPositionRelativeTo", () => { it("should remove the fixedTooltip className if element is not fixed", () => { // Arrange - const stepElement = createElement("div"); + const stepElement = div(); stepElement.style.position = "absolute"; - const helperLayer = createElement("div"); + const helperLayer = div(); helperLayer.className = "introjs-fixedTooltip"; // Act From 6fd5727b976ed952eea0ace94174180827260652 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 09:58:31 +0100 Subject: [PATCH 41/59] refresh unit tests --- src/packages/tour/components/TourTooltip.ts | 3 +- src/packages/tour/mock.ts | 12 ++++--- src/packages/tour/refresh.test.ts | 40 ++++++++++++--------- src/util/sleep.ts | 8 +++++ 4 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 src/util/sleep.ts diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 903e2d087..660f6edbb 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -1,5 +1,5 @@ import { Tooltip, type TooltipProps } from "../../tooltip/tooltip"; -import van, { PropValueOrDerived, State } from "../../dom/van"; +import van, { PropValueOrDerived } from "../../dom/van"; import { activeClassName, bulletsClassName, @@ -20,7 +20,6 @@ import { } from "../classNames"; import { TourStep } from "../steps"; import { dataStepNumberAttribute } from "../dataAttributes"; -import getOffset from "../../../util/getOffset"; import scrollParentToElement from "../../../util/scrollParentToElement"; import scrollTo from "../../../util/scrollTo"; diff --git a/src/packages/tour/mock.ts b/src/packages/tour/mock.ts index 3590a63da..d5550655a 100644 --- a/src/packages/tour/mock.ts +++ b/src/packages/tour/mock.ts @@ -1,4 +1,4 @@ -import createElement from "../../util/createElement"; +import van from "../dom/van" import { TourStep } from "./steps"; import { Tour } from "./tour"; import { @@ -7,22 +7,24 @@ import { dataStepAttribute, } from "./dataAttributes"; +const { div, b, a, h1 } = van.tags; + export const appendMockSteps = (targetElement: HTMLElement = document.body) => { - const mockElementOne = createElement("div"); + const mockElementOne = div(); mockElementOne.setAttribute(dataIntroAttribute, "Mock element"); - const mockElementTwo = createElement("b"); + const mockElementTwo = b(); mockElementTwo.setAttribute(dataIntroAttribute, "Mock element left position"); mockElementTwo.setAttribute(dataPosition, "left"); - const mockElementThree = createElement("h1"); + const mockElementThree = h1(); mockElementThree.setAttribute( dataIntroAttribute, "Mock element second to last" ); mockElementThree.setAttribute(dataStepAttribute, "10"); - const mockElementFour = createElement("a"); + const mockElementFour = a(); mockElementFour.setAttribute(dataIntroAttribute, "Mock element last"); mockElementFour.setAttribute(dataStepAttribute, "20"); diff --git a/src/packages/tour/refresh.test.ts b/src/packages/tour/refresh.test.ts index 7170008cb..3d3bdf82c 100644 --- a/src/packages/tour/refresh.test.ts +++ b/src/packages/tour/refresh.test.ts @@ -1,21 +1,37 @@ -import * as tooltip from "../../packages/tooltip"; import { getMockTour } from "./mock"; +import { Tour } from "./tour"; +import van from "../dom/van"; +import { + sleep, + waitMsForDerivations, + waitMsForExitTransition, +} from "../../util/sleep"; + +const { div } = van.tags; describe("refresh", () => { - test("should not refetch the steps when refreshStep is false", async () => { - // Arrange - jest.spyOn(tooltip, "placeTooltip"); + let mockTour: Tour; + let targetElement: HTMLElement; - const targetElement = document.createElement("div"); - document.body.appendChild(targetElement); + beforeEach(() => { + mockTour = getMockTour(); + targetElement = div(); + van.add(document.body, targetElement); + }); - const mockTour = getMockTour(); + afterEach(async () => { + await mockTour.exit(); + await sleep(waitMsForExitTransition); + }); + test("should not refetch the steps when refreshStep is false", async () => { + // Arrange mockTour.addStep({ intro: "first", }); await mockTour.start(); + await sleep(waitMsForDerivations); // Act mockTour.setOptions({ @@ -35,20 +51,10 @@ describe("refresh", () => { expect(mockTour.getSteps()).toHaveLength(1); expect(mockTour.getStep(0).intro).toBe("first"); expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(1); - - // cleanup - await mockTour.exit(); }); test("should fetch the steps when refreshStep is true", async () => { // Arrange - jest.spyOn(tooltip, "placeTooltip"); - - const targetElement = document.createElement("div"); - document.body.appendChild(targetElement); - - const mockTour = getMockTour(); - mockTour.addStep({ intro: "first", }); diff --git a/src/util/sleep.ts b/src/util/sleep.ts new file mode 100644 index 000000000..540c14689 --- /dev/null +++ b/src/util/sleep.ts @@ -0,0 +1,8 @@ +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +// time to wait for the derivations to update +export const waitMsForDerivations = 5; + +// time to wait for the exit transition to complete +export const waitMsForExitTransition = 260; From 7949b339f505a6969284c3b8a600535127e8b4ac Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 09:59:11 +0100 Subject: [PATCH 42/59] refresh unit tests --- src/packages/tour/refresh.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/packages/tour/refresh.test.ts b/src/packages/tour/refresh.test.ts index 3d3bdf82c..8e500c10f 100644 --- a/src/packages/tour/refresh.test.ts +++ b/src/packages/tour/refresh.test.ts @@ -60,6 +60,7 @@ describe("refresh", () => { }); await mockTour.start(); + await sleep(waitMsForDerivations); // Act mockTour.setOptions({ From e970c32250626aa1ebd874be3e8534050069479d Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 20:32:14 +0100 Subject: [PATCH 43/59] start and steps tests --- src/packages/tour/mock.ts | 2 +- src/packages/tour/start.test.ts | 10 ++++++---- src/packages/tour/steps.test.ts | 20 +++++++++++--------- src/util/elementInViewport.test.ts | 2 +- src/util/positionRelativeTo.test.ts | 4 ++-- src/util/queryElement.ts | 1 - 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/packages/tour/mock.ts b/src/packages/tour/mock.ts index d5550655a..616d3504b 100644 --- a/src/packages/tour/mock.ts +++ b/src/packages/tour/mock.ts @@ -1,4 +1,4 @@ -import van from "../dom/van" +import van from "../dom/van"; import { TourStep } from "./steps"; import { Tour } from "./tour"; import { diff --git a/src/packages/tour/start.test.ts b/src/packages/tour/start.test.ts index 580a7a12d..3bb7b28a2 100644 --- a/src/packages/tour/start.test.ts +++ b/src/packages/tour/start.test.ts @@ -1,6 +1,5 @@ import { start } from "./start"; import * as steps from "./steps"; -import * as addOverlayLayer from "./addOverlayLayer"; import * as nextStep from "./steps"; import { getMockTour } from "./mock"; @@ -10,8 +9,8 @@ describe("start", () => { }); test("should call the onstart callback", () => { + // Arrange jest.spyOn(steps, "fetchSteps").mockReturnValue([]); - jest.spyOn(addOverlayLayer, "default").mockReturnValue(true); jest.spyOn(nextStep, "nextStep").mockReturnValue(Promise.resolve(true)); const onstartCallback = jest.fn(); @@ -19,26 +18,29 @@ describe("start", () => { const mockTour = getMockTour(); mockTour.onStart(onstartCallback); + // Act start(mockTour); + // Assert expect(onstartCallback).toBeCalledTimes(1); expect(onstartCallback).toBeCalledWith(document.body); }); test("should not start the tour if isActive is false", () => { + // Arrange const fetchIntroStepsMock = jest .spyOn(steps, "fetchSteps") .mockReturnValue([]); - const addOverlayLayerMock = jest.spyOn(addOverlayLayer, "default"); const nextStepMock = jest.spyOn(nextStep, "nextStep"); const mockTour = getMockTour(); mockTour.setOption("isActive", false); + // Act start(mockTour); + // Assert expect(fetchIntroStepsMock).toBeCalledTimes(0); - expect(addOverlayLayerMock).toBeCalledTimes(0); expect(nextStepMock).toBeCalledTimes(0); }); diff --git a/src/packages/tour/steps.test.ts b/src/packages/tour/steps.test.ts index a9d65066a..5c2af5b8e 100644 --- a/src/packages/tour/steps.test.ts +++ b/src/packages/tour/steps.test.ts @@ -1,12 +1,14 @@ +import van from "../dom/van"; import { fetchSteps, nextStep, previousStep } from "./steps"; -import _showElement from "./showElement"; import { appendMockSteps, getMockPartialSteps, getMockSteps, getMockTour, } from "./mock"; -import createElement from "../../util/createElement"; +import { showElement } from "./showElement"; + +const { div, h1 } = van.tags; jest.mock("./showElement"); jest.mock("./exitIntro"); @@ -54,7 +56,7 @@ describe("steps", () => { test("should call ShowElement", async () => { // Arrange const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); + (showElement as jest.Mock).mockImplementation(showElementMock); const mockTour = getMockTour(); mockTour.setSteps(getMockSteps()); @@ -88,7 +90,7 @@ describe("steps", () => { // Arrange const mockTour = getMockTour(); const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); + (showElement as jest.Mock).mockImplementation(showElementMock); const fnBeforeChangeCallback = jest.fn(); fnBeforeChangeCallback.mockReturnValue(false); @@ -107,7 +109,7 @@ describe("steps", () => { const mockTour = getMockTour(); mockTour.setSteps(getMockSteps()); const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); + (showElement as jest.Mock).mockImplementation(showElementMock); const onBeforeChangeMock = jest.fn(); const sideEffect: number[] = []; @@ -153,7 +155,7 @@ describe("steps", () => { // Arrange const mockTour = getMockTour(); mockTour.addStep({ - element: createElement("div"), + element: div(), intro: "test step", }); @@ -174,7 +176,7 @@ describe("steps", () => { intro: "first step", }, { - element: createElement("div"), + element: div(), intro: "second step", }, ]); @@ -214,7 +216,7 @@ describe("steps", () => { test("should find and add elements from options.steps to the list", () => { // Arrange - document.body.appendChild(createElement("h1")); + document.body.appendChild(h1()); const mockTour = getMockTour(); mockTour.addSteps(getMockPartialSteps()); @@ -252,7 +254,7 @@ describe("steps", () => { test("should find the data-* elements from the DOM with the correct order", () => { // Arrange - const targetElement = createElement("div"); + const targetElement = div(); const [ mockElementOne, mockElementTwo, diff --git a/src/util/elementInViewport.test.ts b/src/util/elementInViewport.test.ts index bbca28884..3bf202a6f 100644 --- a/src/util/elementInViewport.test.ts +++ b/src/util/elementInViewport.test.ts @@ -1,7 +1,7 @@ import van from "../packages/dom/van"; import elementInViewport from "./elementInViewport"; -const {div} = van.tags; +const { div } = van.tags; describe("elementInViewport", () => { test("should return true when element is in viewport", () => { diff --git a/src/util/positionRelativeTo.test.ts b/src/util/positionRelativeTo.test.ts index 06e896ee7..ff0d6b5d3 100644 --- a/src/util/positionRelativeTo.test.ts +++ b/src/util/positionRelativeTo.test.ts @@ -1,8 +1,8 @@ import { setPositionRelativeTo } from "./positionRelativeTo"; import { getBoundingClientRectSpy } from "../../tests/jest/helper"; -import van from "../packages/dom/van" +import van from "../packages/dom/van"; -const {div} = van.tags; +const { div } = van.tags; describe("setPositionRelativeTo", () => { it("should return if helperLayer or currentStep is null", () => { diff --git a/src/util/queryElement.ts b/src/util/queryElement.ts index 1e50ec64d..195b0d981 100644 --- a/src/util/queryElement.ts +++ b/src/util/queryElement.ts @@ -19,7 +19,6 @@ export const queryElementsByClassName = ( return queryElements(`.${className}`, container); }; - export const getElement = ( selector: string, container?: HTMLElement | null From b5b5b579eb7a7c9df9a249b2e302875c2f57f1bc Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 21:57:45 +0100 Subject: [PATCH 44/59] tour tests --- src/packages/tour/components/TourRoot.ts | 4 +- src/packages/tour/components/TourTooltip.ts | 69 +++++++++++---------- src/packages/tour/tour.test.ts | 13 +++- 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index 52bb69c11..c57c881e1 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -141,8 +141,8 @@ export const TourRoot = ({ tour }: TourRootProps) => { scrollPadding: tour.getOption("scrollPadding"), dontShowAgain: tour.getOption("dontShowAgain"), - onDontShowAgainChange: (e: any) => { - tour.setDontShowAgain((e.target).checked); + onDontShowAgainChange: (checked: boolean) => { + tour.setDontShowAgain(checked); }, dontShowAgainLabel: tour.getOption("dontShowAgainLabel"), }); diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 660f6edbb..c89ec5862 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -8,6 +8,7 @@ import { dontShowAgainClassName, fullButtonClassName, helperNumberLayerClassName, + hiddenButtonClassName, nextButtonClassName, previousButtonClassName, progressBarClassName, @@ -23,7 +24,7 @@ import { dataStepNumberAttribute } from "../dataAttributes"; import scrollParentToElement from "../../../util/scrollParentToElement"; import scrollTo from "../../../util/scrollTo"; -const { h1, div, input, label, ul, li, a, p } = van.tags; +const { h1, div, input, label, ul, li, a } = van.tags; const DontShowAgain = ({ dontShowAgainLabel, @@ -232,8 +233,10 @@ const PrevButton = ({ onClick: (e: any) => void; buttonClass: string; }) => { + const isFirstStep = currentStep === 0 && steps.length > 1; // when the current step is the first one and there are more steps to show - const disabled = currentStep === 0 && steps.length > 1 && !hidePrev; + const isDisabled = isFirstStep && !hidePrev; + const isHidden = isFirstStep && hidePrev; // when the current step is the last one or there is only one step to show const isFullButton = (currentStep === steps.length - 1 || steps.length === 1) && hideNext; @@ -241,17 +244,22 @@ const PrevButton = ({ return Button({ label, onClick, - disabled, + disabled: isDisabled, className: () => { const classNames = [buttonClass, previousButtonClassName]; + if (isFullButton) { classNames.push(fullButtonClassName); } - if (disabled) { + if (isDisabled) { classNames.push(disabledButtonClassName); } + if (isHidden) { + classNames.push(hiddenButtonClassName); + } + return classNames.filter(Boolean).join(" "); }, }); @@ -290,37 +298,30 @@ const Buttons = ({ prevLabel: string; onPrevClick: (e: any) => void; }) => { - const isLastStep = currentStep === steps.length - 1 || steps.length === 1; - const isFirstStep = currentStep === 0 && steps.length > 1; - return div( { className: tooltipButtonsClassName }, - () => - isFirstStep && hidePrev - ? null - : PrevButton({ - label: prevLabel, - steps, - currentStep, - hidePrev, - hideNext, - onClick: onPrevClick, - buttonClass, - }), - () => - isLastStep && hideNext - ? null - : NextButton({ - currentStep, - steps, - doneLabel, - nextLabel, - onClick: onNextClick, - hideNext, - hidePrev, - nextToDone, - buttonClass, - }) + steps.length > 1 + ? PrevButton({ + label: prevLabel, + steps, + currentStep, + hidePrev, + hideNext, + onClick: onPrevClick, + buttonClass, + }) + : null, + NextButton({ + currentStep, + steps, + doneLabel, + nextLabel, + onClick: onNextClick, + hideNext, + hidePrev, + nextToDone, + buttonClass, + }) ); }; @@ -450,7 +451,7 @@ export const TourTooltip = ({ children.push(Header({ title, skipLabel, onSkipClick })); - children.push(div({ className: tooltipTextClassName }, p(text))); + children.push(div({ className: tooltipTextClassName }, text)); if (dontShowAgain) { children.push(DontShowAgain({ dontShowAgainLabel, onDontShowAgainChange })); diff --git a/src/packages/tour/tour.test.ts b/src/packages/tour/tour.test.ts index a8a4bcdd2..4a6acfca7 100644 --- a/src/packages/tour/tour.test.ts +++ b/src/packages/tour/tour.test.ts @@ -1,4 +1,3 @@ -import { queryElementByClassName } from "../../util/queryElement"; import { className, content, @@ -14,6 +13,11 @@ import * as dontShowAgain from "./dontShowAgain"; import { getMockPartialSteps, getMockTour } from "./mock"; import { Tour } from "./tour"; import { helperLayerClassName, overlayClassName } from "./classNames"; +import { + sleep, + waitMsForDerivations, + waitMsForExitTransition, +} from "../../util/sleep"; describe("Tour", () => { beforeEach(() => { @@ -166,12 +170,14 @@ describe("Tour", () => { // Act await mockTour.start(); + await sleep(waitMsForDerivations); await mockTour.exit(); + await sleep(waitMsForExitTransition); // Assert expect(mockElement?.className).not.toContain("introjs-showElement"); - expect(queryElementByClassName(helperLayerClassName)).toBeNull(); - expect(queryElementByClassName(overlayClassName)).toBeNull(); + expect(document.querySelector(`.${helperLayerClassName}`)).toBeNull(); + expect(document.querySelector(`.${overlayClassName}`)).toBeNull(); }); test("should not highlight the target element if queryString is incorrect", async () => { @@ -392,6 +398,7 @@ describe("Tour", () => { // Act await mockTour.start(); + await sleep(waitMsForDerivations); const checkbox = find(".introjs-dontShowAgain input"); checkbox.click(); From fd12b201bf6c3d3ef9a81c2b5387771db0d4248d Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 22:39:13 +0100 Subject: [PATCH 45/59] tooltip tests --- src/packages/tooltip/placeTooltip.test.ts | 224 ++++++---------------- src/packages/tooltip/tooltipPosition.ts | 2 +- 2 files changed, 64 insertions(+), 162 deletions(-) diff --git a/src/packages/tooltip/placeTooltip.test.ts b/src/packages/tooltip/placeTooltip.test.ts index 6f83a6f72..392098fd4 100644 --- a/src/packages/tooltip/placeTooltip.test.ts +++ b/src/packages/tooltip/placeTooltip.test.ts @@ -1,189 +1,91 @@ -import * as getOffset from "../../util/getOffset"; -import * as getWindowSize from "../../util/getWindowSize"; -import { placeTooltip } from "./placeTooltip"; +import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; + +const positionPrecedence: TooltipPosition[] = [ + "bottom", + "top", + "right", + "left", +]; describe("placeTooltip", () => { test("should automatically place the tooltip position when there is enough space", () => { // Arrange - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 1000, - width: 1000, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 200, - left: 200, - bottom: 300, - right: 300, - }); - - const stepElement = document.createElement("div"); - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - // Act - placeTooltip( - tooltipLayer, - arrowLayer, - stepElement, - "top", - ["top", "bottom", "left", "right"], - false, - true - ); - - // Assert - expect(tooltipLayer.className).toBe( - "introjs-tooltip introjs-top-right-aligned" - ); - }); - - test("should skip auto positioning when autoPosition is false", () => { - // Arrange - const stepElement = document.createElement("div"); - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - // Act - placeTooltip( - tooltipLayer, - arrowLayer, - stepElement, + const position = determineAutoPosition( + positionPrecedence, + { + top: 200, + left: 200, + height: 100, + width: 100, + right: 300, + bottom: 300, + absoluteTop: 200, + absoluteLeft: 200, + absoluteRight: 300, + absoluteBottom: 300, + }, + 100, + 100, "top", - ["top", "bottom"], - false, - false + { height: 1000, width: 1000 } ); // Assert - expect(tooltipLayer.className).toBe("introjs-tooltip introjs-top"); + expect(position).toBe("top-right-aligned"); }); test("should use floating tooltips when height/width is limited", () => { // Arrange - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 100, - width: 100, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); - - const stepElement = document.createElement("div"); - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - // Act - placeTooltip( - tooltipLayer, - arrowLayer, - stepElement, - "left", - ["top", "bottom", "left", "right"], - false, - true + const position = determineAutoPosition( + positionPrecedence, + { + top: 0, + left: 0, + height: 100, + width: 100, + right: 0, + bottom: 0, + absoluteTop: 0, + absoluteLeft: 0, + absoluteRight: 0, + absoluteBottom: 0, + }, + 100, + 100, + "top", + { height: 100, width: 100 } ); // Assert - expect(tooltipLayer.className).toBe("introjs-tooltip introjs-floating"); + expect(position).toBe("floating"); }); test("should use bottom middle aligned when there is enough vertical space", () => { // Arrange - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 500, - width: 100, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); - - const stepElement = document.createElement("div"); - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - // Act - placeTooltip( - tooltipLayer, - arrowLayer, - stepElement, - "left", - ["top", "bottom", "left", "right"], - false, - true - ); - - // Assert - expect(tooltipLayer.className).toBe( - "introjs-tooltip introjs-bottom-middle-aligned" - ); - }); - - test("should attach the global custom tooltip css class", () => { - // Arrange - const stepElement = document.createElement("div"); - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - // Act - placeTooltip( - tooltipLayer, - arrowLayer, - stepElement, + const position = determineAutoPosition( + positionPrecedence, + { + top: 0, + left: 0, + height: 100, + width: 100, + right: 0, + bottom: 0, + absoluteTop: 0, + absoluteLeft: 0, + absoluteRight: 0, + absoluteBottom: 0, + }, + 100, + 100, "left", - ["top", "bottom", "left", "right"], - false, - true, - "newClass" + { height: 500, width: 100 } ); // Assert - expect(tooltipLayer.className).toBe( - "introjs-tooltip newClass introjs-bottom-middle-aligned" - ); + expect(position).toBe("bottom-middle-aligned"); }); }); diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts index df66aa2a0..529becf7f 100644 --- a/src/packages/tooltip/tooltipPosition.ts +++ b/src/packages/tooltip/tooltipPosition.ts @@ -17,7 +17,7 @@ export type TooltipPosition = /** * auto-determine alignment */ -export function determineAutoAlignment( +function determineAutoAlignment( offsetLeft: number, tooltipWidth: number, windowWidth: number, From b37ae7ce17f50d2d47ce26f6f28ac7393ffbbb91 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 22:53:59 +0100 Subject: [PATCH 46/59] delete setAnchorAsButton --- src/util/setAnchorAsButton.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/util/setAnchorAsButton.ts diff --git a/src/util/setAnchorAsButton.ts b/src/util/setAnchorAsButton.ts deleted file mode 100644 index c83dcb619..000000000 --- a/src/util/setAnchorAsButton.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Setting anchors to behave like buttons - * - * @api private - */ -export default function setAnchorAsButton(anchor: HTMLElement) { - anchor.setAttribute("role", "button"); - anchor.tabIndex = 0; -} From fa44e6e1666c3e870774888398b305da20cb2030 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Mon, 9 Sep 2024 23:06:33 +0100 Subject: [PATCH 47/59] rename dom package --- .../dom/{van.test.ts => index.test.ts} | 2 +- src/packages/dom/{van.ts => index.ts} | 0 src/packages/hint/components/HintIcon.ts | 6 +-- src/packages/hint/components/HintTooltip.ts | 4 +- src/packages/hint/components/HintsRoot.ts | 8 ++-- .../hint/components/ReferenceLayer.ts | 4 +- src/packages/hint/hint.ts | 10 ++--- src/packages/hint/hintItem.ts | 2 +- src/packages/tooltip/tooltip.ts | 38 +++++++++---------- ...ooltip.test.ts => tooltipPosition.test.ts} | 0 .../tour/components/DisableInteraction.ts | 8 ++-- .../tour/components/FloatingElement.ts | 6 +-- src/packages/tour/components/HelperLayer.ts | 8 ++-- src/packages/tour/components/OverlayLayer.ts | 4 +- .../tour/components/ReferenceLayer.ts | 6 +-- src/packages/tour/components/TourRoot.ts | 10 ++--- src/packages/tour/components/TourTooltip.ts | 8 ++-- src/packages/tour/mock.ts | 4 +- src/packages/tour/refresh.test.ts | 6 +-- src/packages/tour/steps.test.ts | 4 +- src/packages/tour/tour.ts | 10 ++--- src/util/elementInViewport.test.ts | 4 +- src/util/positionRelativeTo.test.ts | 4 +- 23 files changed, 78 insertions(+), 78 deletions(-) rename src/packages/dom/{van.test.ts => index.test.ts} (99%) rename src/packages/dom/{van.ts => index.ts} (100%) rename src/packages/tooltip/{placeTooltip.test.ts => tooltipPosition.test.ts} (100%) diff --git a/src/packages/dom/van.test.ts b/src/packages/dom/index.test.ts similarity index 99% rename from src/packages/dom/van.test.ts rename to src/packages/dom/index.test.ts index 27f46b8aa..455246e0a 100644 --- a/src/packages/dom/van.test.ts +++ b/src/packages/dom/index.test.ts @@ -1,4 +1,4 @@ -import van, { State } from "./van"; +import van, { State } from "."; const { a, diff --git a/src/packages/dom/van.ts b/src/packages/dom/index.ts similarity index 100% rename from src/packages/dom/van.ts rename to src/packages/dom/index.ts diff --git a/src/packages/hint/components/HintIcon.ts b/src/packages/hint/components/HintIcon.ts index fbcd00416..68e4971f4 100644 --- a/src/packages/hint/components/HintIcon.ts +++ b/src/packages/hint/components/HintIcon.ts @@ -1,5 +1,5 @@ import isFixed from "../../../util/isFixed"; -import van, { State } from "../../dom/van"; +import dom, { State } from "../../dom"; import { fixedHintClassName, hideHintClassName, @@ -12,7 +12,7 @@ import { HintItem, HintPosition } from "../hintItem"; import { dataStepAttribute } from "../dataAttributes"; import { alignHintPosition } from "../position"; -const { a, div } = van.tags; +const { a, div } = dom.tags; export type HintProps = { index: number; @@ -60,7 +60,7 @@ export const HintIcon = ({ HintPulse() ); - van.derive(() => { + dom.derive(() => { if (refreshesSignal.val === undefined) return; alignHintPosition( diff --git a/src/packages/hint/components/HintTooltip.ts b/src/packages/hint/components/HintTooltip.ts index 76838776a..28989dd2a 100644 --- a/src/packages/hint/components/HintTooltip.ts +++ b/src/packages/hint/components/HintTooltip.ts @@ -1,9 +1,9 @@ import { Tooltip, TooltipProps } from "../../tooltip/tooltip"; -import van from "../../dom/van"; +import dom from "../../dom"; import { tooltipTextClassName } from "../className"; import { HintItem } from "../hintItem"; -const { a, p, div } = van.tags; +const { a, p, div } = dom.tags; export type HintTooltipProps = Omit< TooltipProps, diff --git a/src/packages/hint/components/HintsRoot.ts b/src/packages/hint/components/HintsRoot.ts index 1481a9f86..288e7b013 100644 --- a/src/packages/hint/components/HintsRoot.ts +++ b/src/packages/hint/components/HintsRoot.ts @@ -1,4 +1,4 @@ -import van from "../../dom/van"; +import dom from "../../dom"; import { hintsClassName } from "../className"; import { hideHint } from "../hide"; import { Hint } from "../hint"; @@ -6,7 +6,7 @@ import { HintItem } from "../hintItem"; import { HintIcon } from "./HintIcon"; import { ReferenceLayer } from "./ReferenceLayer"; -const { div } = van.tags; +const { div } = dom.tags; export type HintsRootProps = { hint: Hint; @@ -54,7 +54,7 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { ...hintElements ); - van.derive(() => { + dom.derive(() => { const activeHintSignal = hint.getActiveHintSignal(); if (activeHintSignal.val === undefined) return; @@ -85,7 +85,7 @@ export const HintsRoot = ({ hint }: HintsRootProps) => { closeButtonOnClick: (hintItem: HintItem) => hideHint(hint, hintItem), }); - van.add(root, referenceLayer); + dom.add(root, referenceLayer); }); return root; diff --git a/src/packages/hint/components/ReferenceLayer.ts b/src/packages/hint/components/ReferenceLayer.ts index 2d6194701..fc1a3b074 100644 --- a/src/packages/hint/components/ReferenceLayer.ts +++ b/src/packages/hint/components/ReferenceLayer.ts @@ -1,5 +1,5 @@ import { setPositionRelativeTo } from "../../../util/positionRelativeTo"; -import van, { State } from "../../dom/van"; +import dom, { State } from "../../dom"; import { hintReferenceClassName, tooltipReferenceLayerClassName, @@ -7,7 +7,7 @@ import { import { dataStepAttribute } from "../dataAttributes"; import { HintTooltip, HintTooltipProps } from "./HintTooltip"; -const { div } = van.tags; +const { div } = dom.tags; export type ReferenceLayerProps = HintTooltipProps & { activeHintSignal: State; diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index 3c0427640..6b1fe851d 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -8,7 +8,7 @@ import DOMEvent from "../../util/DOMEvent"; import { getContainerElement } from "../../util/containerElement"; import { hideHint, hideHints } from "./hide"; import { showHint, showHints } from "./show"; -import van from "../dom/van"; +import dom from "../dom"; import { HintsRoot } from "./components/HintsRoot"; type hintsAddedCallback = (this: Hint) => void | Promise; @@ -20,8 +20,8 @@ export class Hint implements Package { private _hints: HintItem[] = []; private readonly _targetElement: HTMLElement; private _options: HintOptions; - private _activeHintSignal = van.state(undefined); - private _refreshesSignal = van.state(0); + private _activeHintSignal = dom.state(undefined); + private _refreshesSignal = dom.state(0); private readonly callbacks: { hintsAdded?: hintsAddedCallback; @@ -100,7 +100,7 @@ export class Hint implements Package { */ addHint(hint: HintItem): this { // always set isActive to true - hint.isActive = van.state(true); + hint.isActive = dom.state(true); this._hints.push(hint); return this; } @@ -130,7 +130,7 @@ export class Hint implements Package { private createRoot() { this._root = HintsRoot({ hint: this }); - van.add(this._targetElement, this._root); + dom.add(this._targetElement, this._root); } private recreateRoot() { diff --git a/src/packages/hint/hintItem.ts b/src/packages/hint/hintItem.ts index 820de9aff..7ad3aaa0c 100644 --- a/src/packages/hint/hintItem.ts +++ b/src/packages/hint/hintItem.ts @@ -7,7 +7,7 @@ import { dataHintPositionAttribute, dataTooltipClassAttribute, } from "./dataAttributes"; -import { State } from "../dom/van"; +import { State } from "../dom"; export type HintPosition = | "top-left" diff --git a/src/packages/tooltip/tooltip.ts b/src/packages/tooltip/tooltip.ts index ead1dd798..ccde8f6f1 100644 --- a/src/packages/tooltip/tooltip.ts +++ b/src/packages/tooltip/tooltip.ts @@ -1,16 +1,16 @@ import getOffset, { Offset } from "../../util/getOffset"; import getWindowSize from "../../util/getWindowSize"; -import van, { ChildDom, State } from "../dom/van"; +import dom, { ChildDom, State } from "../dom"; import { arrowClassName, tooltipClassName } from "../tour/classNames"; import { determineAutoPosition, TooltipPosition } from "./tooltipPosition"; -const { div } = van.tags; +const { div } = dom.tags; export const TooltipArrow = (props: { tooltipPosition: State; tooltipBottomOverflow: State; }) => { - const classNames = van.derive(() => { + const classNames = dom.derive(() => { const classNames = [arrowClassName]; switch (props.tooltipPosition.val) { @@ -336,28 +336,28 @@ export const Tooltip = ( }: TooltipProps, children?: ChildDom[] ) => { - const top = van.state("auto"); - const right = van.state("auto"); - const bottom = van.state("auto"); - const left = van.state("auto"); - const marginLeft = van.state("auto"); - const marginTop = van.state("auto"); - const opacity = van.state(0); + const top = dom.state("auto"); + const right = dom.state("auto"); + const bottom = dom.state("auto"); + const left = dom.state("auto"); + const marginLeft = dom.state("auto"); + const marginTop = dom.state("auto"); + const opacity = dom.state(0); // setting a default height for the tooltip instead of 0 to avoid flickering // this default is coming from the CSS class and is overridden after the tooltip is rendered - const tooltipHeight = van.state(250); + const tooltipHeight = dom.state(250); // max width of the tooltip according to its CSS class // this default is coming from the CSS class and is overridden after the tooltip is rendered - const tooltipWidth = van.state(300); - const position = van.state(initialPosition); + const tooltipWidth = dom.state(300); + const position = dom.state(initialPosition); // windowSize can change if the window is resized - const windowSize = van.state(getWindowSize()); - const targetOffset = van.state(getOffset(element)); - const tooltipBottomOverflow = van.derive( + const windowSize = dom.state(getWindowSize()); + const targetOffset = dom.state(getOffset(element)); + const tooltipBottomOverflow = dom.derive( () => targetOffset.val!.top + tooltipHeight.val! > windowSize.val!.height ); - van.derive(() => { + dom.derive(() => { // set the new windowSize and targetOffset if the refreshes signal changes if (refreshes.val !== undefined) { windowSize.val = getWindowSize(); @@ -366,7 +366,7 @@ export const Tooltip = ( }); // auto-align tooltip based on position precedence and target offset - van.derive(() => { + dom.derive(() => { if ( position.val !== undefined && initialPosition !== "floating" && @@ -388,7 +388,7 @@ export const Tooltip = ( }); // align tooltip based on position and target offset - van.derive(() => { + dom.derive(() => { if ( tooltipWidth.val !== undefined && tooltipHeight.val !== undefined && diff --git a/src/packages/tooltip/placeTooltip.test.ts b/src/packages/tooltip/tooltipPosition.test.ts similarity index 100% rename from src/packages/tooltip/placeTooltip.test.ts rename to src/packages/tooltip/tooltipPosition.test.ts diff --git a/src/packages/tour/components/DisableInteraction.ts b/src/packages/tour/components/DisableInteraction.ts index 50c9b974a..efa5356ed 100644 --- a/src/packages/tour/components/DisableInteraction.ts +++ b/src/packages/tour/components/DisableInteraction.ts @@ -1,9 +1,9 @@ -import van, { State } from "../../dom/van"; +import dom, { State } from "../../dom"; import { disableInteractionClassName } from "../classNames"; import { setPositionRelativeToStep } from "../position"; import { TourStep } from "../steps"; -const { div } = van.tags; +const { div } = dom.tags; export type HelperLayerProps = { currentStep: State; @@ -20,7 +20,7 @@ export const DisableInteraction = ({ targetElement, helperElementPadding, }: HelperLayerProps) => { - const step = van.derive(() => + const step = dom.derive(() => currentStep.val !== undefined ? steps[currentStep.val] : null ); @@ -33,7 +33,7 @@ export const DisableInteraction = ({ className: disableInteractionClassName, }); - van.derive(() => { + dom.derive(() => { // set the position of the reference layer if the refreshes signal changes if (!step.val || refreshes.val == undefined) return; diff --git a/src/packages/tour/components/FloatingElement.ts b/src/packages/tour/components/FloatingElement.ts index 9d56382c9..ed5216554 100644 --- a/src/packages/tour/components/FloatingElement.ts +++ b/src/packages/tour/components/FloatingElement.ts @@ -1,7 +1,7 @@ -import van, { State } from "../../dom/van"; +import dom, { State } from "../../dom"; import { floatingElementClassName } from "../classNames"; -const { div } = van.tags; +const { div } = dom.tags; export type FloatingElementProps = { currentStep: State; @@ -12,7 +12,7 @@ export const FloatingElement = ({ currentStep }: FloatingElementProps) => { className: floatingElementClassName, }); - van.derive(() => { + dom.derive(() => { // meaning the tour has ended so we should remove the floating element if (currentStep.val === undefined) { floatingElement.remove(); diff --git a/src/packages/tour/components/HelperLayer.ts b/src/packages/tour/components/HelperLayer.ts index 821a917c2..4a1f3eb1d 100644 --- a/src/packages/tour/components/HelperLayer.ts +++ b/src/packages/tour/components/HelperLayer.ts @@ -1,10 +1,10 @@ import { style } from "../../../util/style"; -import van, { State } from "../../dom/van"; +import dom, { State } from "../../dom"; import { helperLayerClassName } from "../classNames"; import { setPositionRelativeToStep } from "../position"; import { TourStep } from "../steps"; -const { div } = van.tags; +const { div } = dom.tags; const getClassName = ({ step, @@ -47,7 +47,7 @@ export const HelperLayer = ({ overlayOpacity, helperLayerPadding, }: HelperLayerProps) => { - const step = van.derive(() => + const step = dom.derive(() => currentStep.val !== undefined ? steps[currentStep.val] : null ); @@ -60,7 +60,7 @@ export const HelperLayer = ({ }), }); - van.derive(() => { + dom.derive(() => { // set the new position if the step or refreshes change if (!step.val || refreshes.val === undefined) return; diff --git a/src/packages/tour/components/OverlayLayer.ts b/src/packages/tour/components/OverlayLayer.ts index dfa9bf39e..624de8062 100644 --- a/src/packages/tour/components/OverlayLayer.ts +++ b/src/packages/tour/components/OverlayLayer.ts @@ -1,9 +1,9 @@ import { style } from "../../../util/style"; import { overlayClassName } from "../classNames"; -import van from "../../dom/van"; +import dom from "../../dom"; import { Tour } from "../tour"; -const { div } = van.tags; +const { div } = dom.tags; export type OverlayLayerProps = { exitOnOverlayClick: boolean; diff --git a/src/packages/tour/components/ReferenceLayer.ts b/src/packages/tour/components/ReferenceLayer.ts index 516e3712e..3aae54189 100644 --- a/src/packages/tour/components/ReferenceLayer.ts +++ b/src/packages/tour/components/ReferenceLayer.ts @@ -1,9 +1,9 @@ -import van from "../../dom/van"; +import dom from "../../dom"; import { tooltipReferenceLayerClassName } from "../classNames"; import { setPositionRelativeToStep } from "../position"; import { TourTooltip, TourTooltipProps } from "./TourTooltip"; -const { div } = van.tags; +const { div } = dom.tags; export type ReferenceLayerProps = TourTooltipProps & { targetElement: HTMLElement; @@ -22,7 +22,7 @@ export const ReferenceLayer = ({ TourTooltip(props) ); - van.derive(() => { + dom.derive(() => { // set the position of the reference layer if the refreshes signal changes if (props.refreshes.val == undefined) return; diff --git a/src/packages/tour/components/TourRoot.ts b/src/packages/tour/components/TourRoot.ts index c57c881e1..16905350a 100644 --- a/src/packages/tour/components/TourRoot.ts +++ b/src/packages/tour/components/TourRoot.ts @@ -1,4 +1,4 @@ -import van from "../../dom/van"; +import dom from "../../dom"; import { ReferenceLayer } from "./ReferenceLayer"; import { HelperLayer } from "./HelperLayer"; import { Tour } from "../tour"; @@ -8,7 +8,7 @@ import { nextStep, previousStep } from "../steps"; import { doneButtonClassName } from "../classNames"; import { style } from "../../../util/style"; -const { div } = van.tags; +const { div } = dom.tags; export type TourRootProps = { tour: Tour; @@ -29,7 +29,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { helperLayerPadding: tour.getOption("helperElementPadding"), }); - const opacity = van.state(0); + const opacity = dom.state(0); // render the tooltip immediately when the tour starts // but we reset the transition duration to 300ms when the tooltip is rendered for the first time let tooltipTransitionDuration = 0; @@ -48,7 +48,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { return null; } - const step = van.derive(() => + const step = dom.derive(() => currentStepSignal.val !== undefined ? steps[currentStepSignal.val] : null @@ -166,7 +166,7 @@ export const TourRoot = ({ tour }: TourRootProps) => { } ); - van.derive(() => { + dom.derive(() => { // to clean up the root element when the tour is done if (currentStepSignal.val === undefined) { opacity.val = 0; diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index c89ec5862..1b0d63aa7 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -1,5 +1,5 @@ import { Tooltip, type TooltipProps } from "../../tooltip/tooltip"; -import van, { PropValueOrDerived } from "../../dom/van"; +import dom, { PropValueOrDerived } from "../../dom"; import { activeClassName, bulletsClassName, @@ -24,7 +24,7 @@ import { dataStepNumberAttribute } from "../dataAttributes"; import scrollParentToElement from "../../../util/scrollParentToElement"; import scrollTo from "../../../util/scrollTo"; -const { h1, div, input, label, ul, li, a } = van.tags; +const { h1, div, input, label, ul, li, a } = dom.tags; const DontShowAgain = ({ dontShowAgainLabel, @@ -177,12 +177,12 @@ const NextButton = ({ const isFullButton = currentStep === 0 && steps.length > 1 && hidePrev; const isLastStep = currentStep === steps.length - 1 || steps.length === 1; - const isDisabled = van.derive(() => { + const isDisabled = dom.derive(() => { // when the current step is the last one or there is only one step to show return isLastStep && !hideNext && !nextToDone; }); - const isDoneButton = van.derive(() => { + const isDoneButton = dom.derive(() => { return isLastStep && !hideNext && nextToDone; }); diff --git a/src/packages/tour/mock.ts b/src/packages/tour/mock.ts index 616d3504b..f8efab675 100644 --- a/src/packages/tour/mock.ts +++ b/src/packages/tour/mock.ts @@ -1,4 +1,4 @@ -import van from "../dom/van"; +import dom from "../dom"; import { TourStep } from "./steps"; import { Tour } from "./tour"; import { @@ -7,7 +7,7 @@ import { dataStepAttribute, } from "./dataAttributes"; -const { div, b, a, h1 } = van.tags; +const { div, b, a, h1 } = dom.tags; export const appendMockSteps = (targetElement: HTMLElement = document.body) => { const mockElementOne = div(); diff --git a/src/packages/tour/refresh.test.ts b/src/packages/tour/refresh.test.ts index 8e500c10f..6da31501e 100644 --- a/src/packages/tour/refresh.test.ts +++ b/src/packages/tour/refresh.test.ts @@ -1,13 +1,13 @@ import { getMockTour } from "./mock"; import { Tour } from "./tour"; -import van from "../dom/van"; +import dom from "../dom"; import { sleep, waitMsForDerivations, waitMsForExitTransition, } from "../../util/sleep"; -const { div } = van.tags; +const { div } = dom.tags; describe("refresh", () => { let mockTour: Tour; @@ -16,7 +16,7 @@ describe("refresh", () => { beforeEach(() => { mockTour = getMockTour(); targetElement = div(); - van.add(document.body, targetElement); + dom.add(document.body, targetElement); }); afterEach(async () => { diff --git a/src/packages/tour/steps.test.ts b/src/packages/tour/steps.test.ts index 5c2af5b8e..fdba7c0da 100644 --- a/src/packages/tour/steps.test.ts +++ b/src/packages/tour/steps.test.ts @@ -1,4 +1,4 @@ -import van from "../dom/van"; +import dom from "../dom"; import { fetchSteps, nextStep, previousStep } from "./steps"; import { appendMockSteps, @@ -8,7 +8,7 @@ import { } from "./mock"; import { showElement } from "./showElement"; -const { div, h1 } = van.tags; +const { div, h1 } = dom.tags; jest.mock("./showElement"); jest.mock("./exitIntro"); diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index cd7665927..ae27fb995 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -19,7 +19,7 @@ import { getDontShowAgain, setDontShowAgain } from "./dontShowAgain"; import { getContainerElement } from "../../util/containerElement"; import DOMEvent from "../../util/DOMEvent"; import onKeyDown from "./onKeyDown"; -import van from "../dom/van"; +import dom from "../dom"; import { TourRoot } from "./components/TourRoot"; import { FloatingElement } from "./components/FloatingElement"; @@ -28,8 +28,8 @@ import { FloatingElement } from "./components/FloatingElement"; */ export class Tour implements Package { private _steps: TourStep[] = []; - private _currentStepSignal = van.state(undefined); - private _refreshesSignal = van.state(0); + private _currentStepSignal = dom.state(undefined); + private _refreshesSignal = dom.state(0); private _root: Element | undefined; private _direction: "forward" | "backward"; private readonly _targetElement: HTMLElement; @@ -413,7 +413,7 @@ export class Tour implements Package { }); // only add the floating element once per tour instance - van.add(this.getTargetElement(), this._floatingElement); + dom.add(this.getTargetElement(), this._floatingElement); } return this._floatingElement; @@ -424,7 +424,7 @@ export class Tour implements Package { */ private createRoot() { this._root = TourRoot({ tour: this }); - van.add(this.getTargetElement(), this._root); + dom.add(this.getTargetElement(), this._root); } /** diff --git a/src/util/elementInViewport.test.ts b/src/util/elementInViewport.test.ts index 3bf202a6f..58daa0cd6 100644 --- a/src/util/elementInViewport.test.ts +++ b/src/util/elementInViewport.test.ts @@ -1,7 +1,7 @@ -import van from "../packages/dom/van"; +import dom from "../packages/dom"; import elementInViewport from "./elementInViewport"; -const { div } = van.tags; +const { div } = dom.tags; describe("elementInViewport", () => { test("should return true when element is in viewport", () => { diff --git a/src/util/positionRelativeTo.test.ts b/src/util/positionRelativeTo.test.ts index ff0d6b5d3..d0cba6465 100644 --- a/src/util/positionRelativeTo.test.ts +++ b/src/util/positionRelativeTo.test.ts @@ -1,8 +1,8 @@ import { setPositionRelativeTo } from "./positionRelativeTo"; import { getBoundingClientRectSpy } from "../../tests/jest/helper"; -import van from "../packages/dom/van"; +import dom from "../packages/dom"; -const { div } = van.tags; +const { div } = dom.tags; describe("setPositionRelativeTo", () => { it("should return if helperLayer or currentStep is null", () => { From 4139027c570d00539bfa1000634f97d00e2e347b Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 10 Sep 2024 07:59:41 +0100 Subject: [PATCH 48/59] Fix hint toggle --- src/packages/hint/hint.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts index 6b1fe851d..75489716e 100644 --- a/src/packages/hint/hint.ts +++ b/src/packages/hint/hint.ts @@ -281,10 +281,15 @@ export class Hint implements Package { if (!item) return; - this._activeHintSignal.val = stepId; + if (this._activeHintSignal.val !== stepId) { + this._activeHintSignal.val = stepId; - // call the callback function (if any) - await this.callback("hintClick")?.call(this, item); + // call the callback function (if any) + await this.callback("hintClick")?.call(this, item); + } else { + // to toggle the hint dialog if the same hint is clicked again + this._activeHintSignal.val = undefined; + } return this; } From b49fc3205d54471ac4c5415423221b7e793ff576 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 10 Sep 2024 09:54:32 +0100 Subject: [PATCH 49/59] do not recreate root twice in tour --- src/packages/tour/tour.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts index ae27fb995..d1b6f1d76 100644 --- a/src/packages/tour/tour.ts +++ b/src/packages/tour/tour.ts @@ -423,8 +423,10 @@ export class Tour implements Package { * Create the root element for the tour */ private createRoot() { - this._root = TourRoot({ tour: this }); - dom.add(this.getTargetElement(), this._root); + if (!this._root) { + this._root = TourRoot({ tour: this }); + dom.add(this.getTargetElement(), this._root); + } } /** @@ -433,6 +435,7 @@ export class Tour implements Package { private recreateRoot() { if (this._root) { this._root.remove(); + this._root = undefined; this.createRoot(); } } From 19447e538dddf66a600def95c159e1eb8e8e3586 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 10 Sep 2024 10:31:57 +0100 Subject: [PATCH 50/59] highlight.cy --- src/packages/tour/highlight.cy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/packages/tour/highlight.cy.ts b/src/packages/tour/highlight.cy.ts index eae7d71d8..724df4748 100644 --- a/src/packages/tour/highlight.cy.ts +++ b/src/packages/tour/highlight.cy.ts @@ -52,6 +52,7 @@ context("Highlight", () => { let sp = cy.spy(window, "click"); cy.nextStep(); + cy.wait(500); cy.get(".introjs-tooltiptext").contains("step two"); cy.get(".introjs-helperLayer").realHover(); @@ -78,6 +79,7 @@ context("Highlight", () => { }) .start(); + cy.wait(500); let sp = cy.spy(window, "click"); cy.get(".introjs-helperLayer").realHover(); @@ -133,7 +135,9 @@ context("Highlight", () => { }, ], }) - .start(); + .start(); + + cy.wait(500); let sp = cy.spy(window, "clickRelative"); @@ -192,6 +196,8 @@ context("Highlight", () => { }) .start(); + cy.wait(500); + let sp = cy.spy(window, "clickAbsolute"); cy.get(".introjs-helperLayer").realHover(); From 0eec7fc9f90f12fa0e85185934cff97f91a75b8d Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 10 Sep 2024 21:24:30 +0100 Subject: [PATCH 51/59] Fix incorrect progress calc --- src/packages/tour/components/TourTooltip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/tour/components/TourTooltip.ts b/src/packages/tour/components/TourTooltip.ts index 1b0d63aa7..1bcdea20d 100644 --- a/src/packages/tour/components/TourTooltip.ts +++ b/src/packages/tour/components/TourTooltip.ts @@ -96,7 +96,7 @@ const ProgressBar = ({ currentStep: number; progressBarAdditionalClass: string; }) => { - const progress = (currentStep / steps.length) * 100; + const progress = ((currentStep + 1) / (steps.length)) * 100; return div({ className: progressClassName }, [ div({ From 62103b17f7d6a9788ef91f0d4042a9fc5fc57f55 Mon Sep 17 00:00:00 2001 From: binrysearch Date: Tue, 10 Sep 2024 22:06:46 +0100 Subject: [PATCH 52/59] upgrade cypress-visual-regression --- cypress.config.ts | 3 ++- cypress/setup/index.html | 8 ++++---- ...> dont-show-again-checkbox-first-step.png} | Bin ... dont-show-again-checkbox-second-step.png} | Bin ...=> dont-show-again-clicked-after-exit.png} | Bin ...show-again-clicked-after-second-start.png} | Bin ...=> dont-show-again-clicked-first-step.png} | Bin ...p.png => highlight-element-first-step.png} | Bin ....png => highlight-element-second-step.png} | Bin ...png => highlight-fixed-element-scroll.png} | Bin ...lement.png => highlight-fixed-element.png} | Bin ...highlight-fixed-parent-element-scroll.png} | Bin .../{first_step.png => first-step.png} | Bin ...osition_bottom.png => position-bottom.png} | Bin .../{position_left.png => position-left.png} | Bin ...{position_right.png => position-right.png} | Bin ..._first_step.png => refresh-first-step.png} | Bin ...econd_step.png => refresh-second-step.png} | Bin ..._third_step.png => refresh-third-step.png} | Bin .../{second_step.png => second-step.png} | Bin .../{first_step.png => first-step.png} | Bin .../{second_step.png => second-step.png} | Bin cypress/support/index.ts | 1 + package-lock.json | 14 +++++++------- package.json | 2 +- src/packages/tour/dont-show-again.cy.ts | 10 +++++----- src/packages/tour/highlight.cy.ts | 13 +++++-------- src/packages/tour/modal.cy.ts | 18 +++++++++--------- src/packages/tour/progressbar.cy.ts | 6 +++--- 29 files changed, 37 insertions(+), 38 deletions(-) rename cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/{dont_show_again_checkbox_first_step.png => dont-show-again-checkbox-first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/{dont_show_again_checkbox_second_step.png => dont-show-again-checkbox-second-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/{dont_show_again_clicked_after_exit.png => dont-show-again-clicked-after-exit.png} (100%) rename cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/{dont_show_again_clicked_after_second_start.png => dont-show-again-clicked-after-second-start.png} (100%) rename cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/{dont_show_again_clicked_first_step.png => dont-show-again-clicked-first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/highlight.cy.ts/{highlight_element_first_step.png => highlight-element-first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/highlight.cy.ts/{highlight_element_second_step.png => highlight-element-second-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/highlight.cy.ts/{highlight_fixed_element_scroll.png => highlight-fixed-element-scroll.png} (100%) rename cypress/snapshots/base/src/packages/tour/highlight.cy.ts/{highlight_fixed_element.png => highlight-fixed-element.png} (100%) rename cypress/snapshots/base/src/packages/tour/highlight.cy.ts/{highlight_fixed_parent_element_scroll.png => highlight-fixed-parent-element-scroll.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{first_step.png => first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{position_bottom.png => position-bottom.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{position_left.png => position-left.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{position_right.png => position-right.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{refresh_first_step.png => refresh-first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{refresh_second_step.png => refresh-second-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{refresh_third_step.png => refresh-third-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/modal.cy.ts/{second_step.png => second-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/{first_step.png => first-step.png} (100%) rename cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/{second_step.png => second-step.png} (100%) diff --git a/cypress.config.ts b/cypress.config.ts index a365f0dac..142e38ef3 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from "cypress"; import { configureVisualRegression } from "cypress-visual-regression"; module.exports = defineConfig({ + viewportWidth: 1000, trashAssetsBeforeRuns: true, env: { - failSilently: false, + visualRegressionFailSilently: false, }, e2e: { screenshotsFolder: "./cypress/snapshots/actual", diff --git a/cypress/setup/index.html b/cypress/setup/index.html index c2a6e9cbb..180467dd8 100644 --- a/cypress/setup/index.html +++ b/cypress/setup/index.html @@ -14,15 +14,15 @@ crossorigin="anonymous" /> - - - + + +