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,