Lesson 43 of 45 · Web pages

State, the DOM and timers

The last three lessons showed single events. A real page puts them together: it remembers something, reacts to a click or a key, and then updates what the reader sees. This lesson teaches that loop with small complete pages, a counter, a todo list, tabs, a stopwatch, a quiz, and the dom functions each one needs.

State lives in variables

An interactive page is a loop of three steps: something happens, the program changes what it remembers, and the page is redrawn from what the program remembers. What the program remembers is its state. In Salam the state of a page is a handful of top level mut variables above the page: block. Handlers change them, and one function, usually called draw or show, copies them into the page.

web-dom-counter.salam
import dom

mut count := 0

func draw:
    shown := dom.ById("value")
    dom.SetText(shown, "" + count)
    if count < 0:
        dom.SetStyle(shown, "color", "#dc2626")
    else:
        dom.SetStyle(shown, "color", "#0f172a")
    end
end

func more:
    count += 1
    draw()
end

func fewer:
    count -= 1
    draw()
end

func start_over:
    count = 0
    draw()
end

page:
    title = "Counter"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
    global: selector = "button" padding = "8px 18px" font size = "1.1rem" border = "1px solid #cbd5e1" border radius = 8 background = "#f8fafc" cursor = pointer end
    global: selector = "button:hover" background = "#e0e7ff" end
    box:
        max width = "16rem" margin = "0 auto" padding = 20 text align = center border = "1px solid #e2e8f0" border radius = 14
        heading: id = "value" size = 1 content = "0" font size = "4rem" margin = "0 0 12px 0" end
        button: id = "minus" click = fewer content = "-1" end
        button: id = "plus" click = more content = "+1" margin = "0 8px" end
        button: id = "zero" click = start_over content = "Reset" end
    end
end

Look at how the example is split. count is the only place that knows the number. fewer, more and start_over change it and call draw, and draw is the only function that touches the page. When something looks wrong on screen you know where to look: either the state is wrong or the drawing is. The red colour of a negative number is a rule inside draw, so it is applied every time, whichever handler ran.

The helpers come from import dom, and the whole list is on the dom package page. This lesson teaches the page side: which helper to reach for and how the parts fit together.

A page program does not need a main and nothing calls one. If you add one, the compiler starts counting only what main calls, and your handlers are reported as unused functions (error E066). Put the work in handlers, and see the storage section for code that must run when the page opens.

Finding elements, reading and writing them

To change an element you first need a handle on it. dom.ById("value") returns the element with that id, and the compiler checks the id for you: if no element of the same page has it, the build stops with error E138, so a typing mistake is found before the page ever runs.

E138: no element with id 'result2' in this file's layout

The check only sees ids written as plain text. An id built while the page runs, such as dom.ById("votes-" + name), is not checked, so a typing mistake there shows up as an error in the browser console instead. When there is no id to use, dom.Query(".note") returns the first element that matches a CSS selector, and dom.QueryAll("#books li") returns all of them as a list. Read a list with dom.CountOf(list) and dom.ItemAt(list, i), counting from 0.

With a handle you can read and write. dom.SetText(el, "...") and dom.GetText(el) deal with text, and dom.SetValue(el, "...") and dom.GetValue(el) with what is typed in a field. The character counter below reads the box on every input event, shows the count and the share of the limit, and switches the button off while the note is empty or too long.

web-dom-charcount.salam
import dom

const LIMIT := 80

func measure:
    used := dom.GetNum(dom.ById("note"), "textLength") as i64
    dom.SetText(dom.ById("used"), "" + used + " / " + LIMIT)
    if used > LIMIT:
        dom.SetStyle(dom.ById("used"), "color", "#dc2626")
        dom.SetStyle(dom.ById("bar"), "width", "100%")
        dom.SetStyle(dom.ById("bar"), "background", "#dc2626")
    else:
        dom.SetStyle(dom.ById("used"), "color", "#475569")
        dom.SetStyle(dom.ById("bar"), "width", "" + used * 100 / LIMIT + "%")
        dom.SetStyle(dom.ById("bar"), "background", "#4f46e5")
    end
    dom.SetBool(dom.ById("post"), "disabled", used == 0 or used > LIMIT)
end

page:
    title = "Character counter"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "28rem" end
    text area: id = "note" input = measure rows = 3 placeholder = "Write a short note" width = "100%" padding = 10 font size = "1rem" font family = inherit box sizing = "border-box" border radius = 8 border = "1px solid #cbd5e1" end
    box: height = 6 background = "#e2e8f0" border radius = 3 margin = "6px 0"
        box: id = "bar" height = "100%" width = "0%" background = "#4f46e5" border radius = 3 transition = "width 0.15s" end
    end
    box: display = flex justify content = space-between align items = center
        span: id = "used" content = "0 / 80" color = "#475569" end
        button: id = "post" disabled = true content = "Post" padding = "6px 16px" end
    end
end

The count comes from dom.GetNum(field, "textLength"), a property the browser keeps for a text area, and not from text.len(). In the browser a Salam string counts UTF-8 bytes, so the Persian word سلام would count as 8. GetNum, GetStr, GetBool and GetProp read any property of an element, and they are how you reach whatever the other helpers do not cover.

A list of elements is what a live filter needs. The handler below goes through every row, shows the ones that contain the search text and hides the rest, counts how many stayed, and shows a message when none did.

web-dom-filter.salam
import dom

func narrow:
    needle := dom.GetValue(dom.ById("q")).lower()
    rows := dom.QueryAll("#books li")
    mut shown := 0
    repeat dom.CountOf(rows) in i:
        row := dom.ItemAt(rows, i)
        if dom.GetText(row).lower().includes(needle):
            dom.SetStyle(row, "display", "")
            shown += 1
        else:
            dom.SetStyle(row, "display", "none")
        end
    end
    dom.SetText(dom.ById("found"), "" + shown + " of " + dom.CountOf(rows))
    if shown == 0:
        dom.SetStyle(dom.ById("none"), "display", "block")
    else:
        dom.SetStyle(dom.ById("none"), "display", "none")
    end
end

page:
    title = "Filter"
    style: content = "#books li { padding: 10px 12px; margin: 6px 0; background: #f1f5f9; border-radius: 8px }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "26rem" end
    input: id = "q" type = search input = narrow placeholder = "Search the shelf" width = "100%" padding = 10 font size = "1rem" box sizing = "border-box" border radius = 8 border = "1px solid #cbd5e1" end
    paragraph: id = "found" content = "6 of 6" color = "#475569" margin = "6px 0" end
    list: id = "books" list style = none padding = 0
        item: content = "The Little Prince" end
        item: content = "Pride and Prejudice" end
        item: content = "The Hobbit" end
        item: content = "Little Women" end
        item: content = "Moby Dick" end
        item: content = "Great Expectations" end
    end
    paragraph: id = "none" content = "Nothing on the shelf matches." display = none color = "#b45309" end
end

A list from QueryAll is a snapshot. Rows added later are not in it, so ask again each time, as the handler does.

One more way to write is dom.SetHtml(el, "..."). It makes the browser read the string as HTML, so it can build a whole fragment in one call, and dom.SetHtml(el, "") empties an element. That is also the danger. A name typed by a visitor and put in with SetHtml is run as HTML, and a name such as <img src=x onerror=...> becomes a program. Text that came from outside always goes in with SetText, which shows every character as it is:

func echo:
    dom.SetText(dom.ById("shown"), dom.GetValue(dom.ById("name")))
end

page:
    input: id = "name" input = echo placeholder = "Your name" end
    paragraph: id = "shown" end
end

A missing element is not an empty value. dom.ById and dom.Query give JavaScript null for an element that is not there, and using it stops the handler with an error in the console. When an element may be absent, ask first with dom.Exists("banner"), as in if dom.Exists("banner"): dom.Remove(dom.ById("banner")) end.

Classes, styles and attributes

There are three ways to change how an element looks or behaves, and the order of preference is the order below. First, classes: dom.AddClass, dom.RemoveClass and dom.ToggleClass switch a class on and off, and the CSS for it is written once, in a style element. The script says what state the element is in, the style sheet says what that looks like, and the two stay apart. The theme switch below changes one class on the body, and every colour on the page follows.

web-dom-theme.salam
import dom

mut dark := false

func switch_theme:
    dom.ToggleClass(dom.Body(), "night")
    dark = not dark
    if dark:
        dom.SetText(dom.ById("flip"), "Use the day theme")
    else:
        dom.SetText(dom.ById("flip"), "Use the night theme")
    end
end

page:
    title = "Theme"
    style: content = "body { background: #f8fafc; color: #0f172a; transition: background 0.3s, color 0.3s } body.night { background: #0f172a; color: #e2e8f0 } body.night .card { background: #1e293b; border-color: #334155 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = 0 padding = "1.5rem" min height = "12rem" end
    box:
        class = "card"
        max width = "26rem" padding = 20 background = "white" border = "1px solid #e2e8f0" border radius = 14
        heading: size = 2 content = "Good evening" margin = "0 0 8px 0" end
        paragraph: content = "One class on the body changes every colour on the page." end
        button: id = "flip" click = switch_theme content = "Use the night theme" padding = "8px 16px" border radius = 8 border = "1px solid #6366f1" background = "#6366f1" color = white cursor = pointer end
    end
end

Second, styles: dom.SetStyle(el, "width", "40%") sets one CSS property of one element, with its CSS name (background-color, not backgroundColor), and an empty value removes it again. It is the right tool for a value you work out while running, such as the width of the bar in the character counter. dom.GetStyle(el, "display") returns the value the browser is using now, for example none for a hidden element.

Third, attributes: dom.SetAttr(el, "aria-expanded", "true") and dom.GetAttr(el, "aria-expanded") read and write HTML attributes as text. They matter most for the accessibility attributes, because a screen reader reads aria-expanded and ignores the class. This accordion keeps both in step: the class shows or hides the panel, and the attribute tells assistive software whether it is open.

web-dom-accordion.salam
import dom

func flip(name: str):
    head := dom.ById("h-" + name)
    pane := dom.ById("p-" + name)
    dom.ToggleClass(pane, "open")
    if dom.GetAttr(head, "aria-expanded") == "true":
        dom.SetAttr(head, "aria-expanded", "false")
    else:
        dom.SetAttr(head, "aria-expanded", "true")
    end
end

component Fold(name, title):
    box:
        class = "fold"
        button: id = "h-" + name aria expanded = "false" click = (): flip(name) end content = title end
        box: id = "p-" + name class = "pane"
            content: end
        end
    end
end

page:
    title = "Accordion"
    style: content = ".pane { display: none; padding: 0 14px 12px } .pane.open { display: block } .fold button { width: 100%; text-align: start; padding: 12px 14px; font-size: 1rem; border: 0; background: #f1f5f9; cursor: pointer } .fold button:hover { background: #e0e7ff } .fold button[aria-expanded=true] { font-weight: bold }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "30rem" end
    global: selector = ".fold" border = "1px solid #e2e8f0" border radius = 10 overflow = hidden margin = "0 0 8px 0" end
    Fold: name = "ship" title = "How long does delivery take?"
        paragraph: content = "Two to four working days inside the country." end
    end
    Fold: name = "back" title = "Can I return an item?"
        paragraph: content = "Yes, within thirty days, if it is unused." end
    end
    Fold: name = "pay" title = "Which payment methods do you accept?"
        paragraph: content = "Cards and bank transfer." end
    end
end

The accordion is a component, and its handler remembers the name it was made with. The section on reuse comes back to that. The browser also has the details element for exactly this job, with no script at all (see the lesson on page structure). The accordion is here because it shows state, classes and attributes working together.

Some things are not attributes but properties of the element: checked, disabled, value. The attribute only gives the starting value, and the property is the live one. Read and write those with dom.GetBool and dom.SetBool:

func agree_changed:
    ticked := dom.GetBool(dom.ById("agree"), "checked")
    dom.SetBool(dom.ById("join"), "disabled", not ticked)
end

page:
    input: id = "agree" type = checkbox change = agree_changed end
    label: for = "agree" content = "I accept the rules" end
    button: id = "join" disabled = true content = "Join" end
end

Tabs put the three tools together. One handler goes through every tab and every panel, and the one with the chosen number gets the class on and aria-selected = true while all the others lose them.

web-dom-tabs.salam
import dom

func show_tab(n: int):
    tabs := dom.QueryAll(".tab")
    sheets := dom.QueryAll(".sheet")
    repeat dom.CountOf(tabs) in i:
        tab := dom.ItemAt(tabs, i)
        sheet := dom.ItemAt(sheets, i)
        if i == n:
            dom.AddClass(tab, "on")
            dom.AddClass(sheet, "on")
            dom.SetAttr(tab, "aria-selected", "true")
        else:
            dom.RemoveClass(tab, "on")
            dom.RemoveClass(sheet, "on")
            dom.SetAttr(tab, "aria-selected", "false")
        end
    end
end

page:
    title = "Tabs"
    style: content = ".tab { padding: 10px 18px; border: 0; border-bottom: 3px solid transparent; background: none; font-size: 1rem; cursor: pointer } .tab:hover { background: #f1f5f9 } .tab.on { border-bottom-color: #4f46e5; font-weight: bold } .sheet { display: none; padding: 14px 4px } .sheet.on { display: block }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "32rem" end
    box: role = tablist border bottom = "1px solid #e2e8f0"
        button: class = "tab on" aria selected = "true" click = (): show_tab(0) end content = "Overview" end
        button: class = "tab" aria selected = "false" click = (): show_tab(1) end content = "Recipe" end
        button: class = "tab" aria selected = "false" click = (): show_tab(2) end content = "Reviews" end
    end
    box: class = "sheet on" content = "A flat bread baked on a hot stone in about four minutes." end
    box: class = "sheet" content = "Flour, water, salt and a spoon of yeast. Rest the dough for an hour." end
    box: class = "sheet" content = "Four and a half stars from 212 readers." end
end

When two things have to change together, put them in one function and call it, as show_tab does. If the tab buttons and the panels were updated in different places, sooner or later one of them would be forgotten.

Making and removing elements

A page often has to grow: a new row in a list, a new message. dom.Create("li") makes an element that is not on the page yet, dom.SetText and dom.SetAttr fill it in, and dom.Append(parent, child) puts it at the end of the parent. dom.Remove(el) takes it out again. The todo list below does all of it.

web-dom-todo.salam
import dom

func refresh:
    left := dom.CountOf(dom.QueryAll("#tasks li"))
    dom.SetText(dom.ById("left"), "" + left + " to do")
end

func add_task(e: i64):
    dom.PreventDefault(e)
    field := dom.ById("name")
    text := dom.GetValue(field)
    if text.len() > 0:
        row := dom.Create("li")
        label := dom.Create("span")
        dom.SetText(label, text)
        gone := dom.Create("button")
        dom.SetText(gone, "Done")
        dom.OnEvent(gone, "click", (): dom.Remove(row) refresh() end)
        dom.Append(row, label)
        dom.Append(row, gone)
        dom.Append(dom.ById("tasks"), row)
        dom.SetValue(field, "")
        refresh()
    end
end

page:
    title = "To do"
    style: content = "#tasks li { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; margin: 6px 0; background: #f1f5f9; border-radius: 8px } #tasks button { border: 0; background: #e0e7ff; border-radius: 6px; padding: 4px 10px; cursor: pointer }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "26rem" end
    form:
        id = "new" submit = add_task display = flex gap = 8
        input: id = "name" placeholder = "What needs doing?" flex = 1 padding = 8 font size = "1rem" end
        button: id = "add" type = submit content = "Add" padding = "8px 16px" border = "0" background = "#4f46e5" color = white border radius = 8 cursor = pointer end
    end
    list: id = "tasks" list style = none padding = 0 end
    paragraph: id = "left" content = "0 to do" color = "#475569" end
end

Read add_task from the top. It reads the field, makes a li with a span for the text and a button, puts them together, adds the row to the list, clears the field and updates the count. The count is not a variable: refresh counts the rows that are on the page, so it can never disagree with them. The page is its own state here, which is fine for a number that is easy to read back.

The new button needs a handler, and an element that does not exist when the page is written cannot have a click attribute. dom.OnEvent(el, "click", handler) adds one while the page runs. The handler is a lambda, and it remembers the variables around it, so the lambda made for one row removes that row. When the handler must see the event, use dom.OnEventE and a lambda with an i64 parameter instead, such as (e: i64): hello(e) end.

The form of the example matters too. The field and the button sit in a form with submit = add_task, so the Enter key adds a task as well as the button, with no key handling written. The handler calls dom.PreventDefault(e), because otherwise the browser would send the form and reload the page.

Text from a user goes into a new element with dom.SetText, never into SetHtml, for the reason given above. Building elements one by one is longer to write, and it is safe.

What an event tells you

A handler declared with an i64 parameter receives the event, and a few helpers read it. dom.EventTarget(e) is the element that was hit, dom.EventX(e) and dom.EventY(e) the position of the pointer, dom.EventKey(e) the key, dom.PreventDefault(e) cancels what the browser would do next and dom.StopPropagation(e) stops the event reaching the elements above. Each has its lesson: mouse events, keyboard and forms, drag, drop and touch and media and the rest.

EventTarget is what lets one handler serve many elements. The seat picker below has eight identical buttons made with repeat = 8, which cannot have eight different ids. The handler asks the event which one was clicked, flips its class, and counts the seats that have it.

web-dom-seats.salam
import dom

func pick_seat(e: i64):
    dom.ToggleClass(dom.EventTarget(e), "taken")
    taken := dom.CountOf(dom.QueryAll(".seat.taken"))
    dom.SetText(dom.ById("summary"), "" + taken + " of 8 seats chosen")
end

page:
    title = "Seats"
    style: content = ".seat { width: 44px; height: 44px; border: 2px solid #94a3b8; border-radius: 8px 8px 14px 14px; background: white; cursor: pointer } .seat:hover { border-color: #4f46e5 } .seat.taken { background: #4f46e5; border-color: #4f46e5 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
    box: display = grid grid template columns = "repeat(4, 44px)" gap = 10 justify content = start
        button: repeat = 8 class = "seat" click = pick_seat aria label = "Seat" end
    end
    paragraph: id = "summary" content = "0 of 8 seats chosen" color = "#475569" end
end

Timers and frames

Three helpers run a function later. dom.SetTimeout(fn, ms) runs it once after that many milliseconds. dom.SetInterval(fn, ms) runs it again and again. Both return a number, and dom.ClearTimeout(id) and dom.ClearInterval(id) cancel the timer with that number. Keep the number in a top level variable declared mut id := 0 as i64, because a timer you cannot cancel keeps running for as long as the page is open. The functions take a function without parameters, so pass a lambda: (): tick() end.

The stopwatch starts an interval of 100 milliseconds when you press Start, adds one tenth of a second on every tick, and clears the interval when you press Stop. The toast does it with a timeout: it shows a message, and a second click first cancels the waiting timeout so that the message stays for a full two seconds from the last click.

web-dom-stopwatch.salam
import dom

mut tenths := 0
mut ticking := 0 as i64
mut running := false

func draw:
    dom.SetText(dom.ById("time"), "" + tenths / 600 + ":" + tenths / 10 % 60 / 10 + tenths / 10 % 60 % 10 + "." + tenths % 10)
end

func tick:
    tenths += 1
    draw()
end

func start_stop:
    if running:
        dom.ClearInterval(ticking)
        running = false
        dom.SetText(dom.ById("go"), "Start")
    else:
        ticking = dom.SetInterval((): tick() end, 100)
        running = true
        dom.SetText(dom.ById("go"), "Stop")
    end
end

func zero:
    tenths = 0
    draw()
end

page:
    title = "Stopwatch"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
    global: selector = "button" padding = "8px 20px" font size = "1.05rem" border = "1px solid #cbd5e1" border radius = 8 background = "#f8fafc" cursor = pointer end
    box:
        max width = "16rem" margin = "0 auto" padding = 20 text align = center border = "1px solid #e2e8f0" border radius = 14
        paragraph: id = "time" content = "0:00.0" font size = "3rem" font family = "ui-monospace, monospace" margin = "0 0 12px 0" end
        button: id = "go" click = start_stop content = "Start" end
        button: id = "clear" click = zero content = "Reset" margin = "0 0 0 8px" end
    end
end
web-dom-toast.salam
import dom

mut waiting := 0 as i64

func hide_toast:
    dom.RemoveClass(dom.ById("toast"), "show")
end

func copied:
    dom.ClearTimeout(waiting)
    dom.AddClass(dom.ById("toast"), "show")
    waiting = dom.SetTimeout((): hide_toast() end, 2000)
end

page:
    title = "Toast"
    style: content = "#toast { opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none } #toast.show { opacity: 1; transform: none }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
    button: id = "copy" click = copied content = "Copy the link" padding = "10px 20px" font size = "1rem" border radius = 8 border = "1px solid #4f46e5" background = "#4f46e5" color = white cursor = pointer end
    box: id = "toast" role = status content = "Link copied. This message goes away in two seconds." margin = "14px 0 0 0" padding = "10px 14px" background = "#0f172a" color = white border radius = 8 max width = "20rem" end
end

A browser does not promise that an interval is exact. It can run late, and a tab in the background runs it a lot less often. The stopwatch counts ticks, which is simple and good enough to show the idea. For a clock that must be right, remember the start with dom.Now() (milliseconds since the page opened) and show the difference on every tick.

For movement use dom.RequestFrame(fn). It runs the function once, just before the browser draws the next picture, and passes the time in milliseconds. To keep going, the function asks for the next frame itself. This is smoother than an interval because it follows the screen. The ball below is placed from the time that has passed, and not by adding a fixed step, so it takes the same time on a slow screen and on a fast one.

web-dom-slide.salam
import dom

mut began := 0.0

func slide(t: f64):
    mut left := (t - began) / 4.0
    if left < 240.0:
        dom.RequestFrame((next: f64): slide(next) end)
    else:
        left = 240.0
    end
    dom.SetStyle(dom.ById("ball"), "left", "" + left + "px")
end

func launch:
    began = dom.Now()
    dom.RequestFrame((t: f64): slide(t) end)
end

page:
    title = "Slide"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
    box: position = relative width = 280 height = 44 background = "#e0e7ff" border radius = 22
        box: id = "ball" position = absolute top = 4 left = 0 width = 36 height = 36 background = "#4f46e5" border radius = "50%" end
    end
    button: id = "go" click = launch content = "Slide" margin = "14px 0 0 0" padding = "8px 18px" border = "0" background = "#4f46e5" color = white border radius = 8 cursor = pointer end
end

If you want a loop that never stops, such as a game, dom.EveryFrame does the asking for you. The CSS lesson on transitions and animations comes first, though: when CSS can do the movement, let it.

Remembering after the page closes

Variables vanish when the page is closed. dom.StoreSetStr("note", text) keeps a string in the browser's localStorage, dom.StoreGetStr("note") gives it back (an empty string when nothing is stored), and StoreSetInt and StoreGetInt do the same for whole numbers. The data stays on the reader's device, for that site only, until it is cleared. Never store a password or anything private there, and never trust what you read back: it is only a convenience, and the visitor can change it.

Storage can be unavailable. A private window, a blocked setting or an embedded frame like the preview below can refuse it. The helpers do not stop the page when that happens: a write is dropped and a read gives an empty string, with a warning in the console. Your code should still behave. This note saves as you type and reads the value back to check it, so it can tell the reader when the browser would not keep it. Press the button, or reload a real page, to load the saved note.

web-dom-notes.salam
import dom

func save_note:
    text := dom.GetValue(dom.ById("text"))
    dom.StoreSetStr("note", text)
    if dom.StoreGetStr("note") == text:
        dom.SetText(dom.ById("state"), "Saved on this device.")
    else:
        dom.SetText(dom.ById("state"), "This browser would not keep it.")
    end
end

func load_note:
    saved := dom.StoreGetStr("note")
    if saved.len() > 0:
        dom.SetValue(dom.ById("text"), saved)
        dom.SetText(dom.ById("state"), "Loaded your saved note.")
    else:
        dom.SetText(dom.ById("state"), "Nothing saved yet.")
    end
end

page:
    title = "Notes"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "28rem" end
    text area: id = "text" input = save_note rows = 4 placeholder = "Your note" width = "100%" padding = 10 font size = "1rem" box sizing = "border-box" border radius = 8 border = "1px solid #cbd5e1" end
    box: display = flex justify content = space-between align items = center margin = "6px 0"
        span: id = "state" content = "Nothing saved yet." color = "#475569" end
        button: id = "again" click = load_note content = "Load saved note" padding = "6px 14px" end
    end
    script: content = "window.addEventListener('DOMContentLoaded', function () { load_note(); });" end
end

The last line of the page is a small script element. The page: block has no start-up hook, so this line asks the browser to run load_note once the page has been read. It can name the function because a function used as an event handler (here the button's click) is also placed where the browser can find it. Without this line the page would load the note only when you press the button.

Alerts, questions and prompts

Three helpers use the browser's own dialogs: dom.Alert(message) shows a message, dom.Confirm(question) gives true or false, and dom.Prompt(question) gives the text the reader typed, or an empty string when they cancelled.

func wipe:
    if dom.Confirm("Delete all notes?"):
        dom.SetText(dom.ById("notes"), "")
    end
end

page:
    paragraph: id = "notes" content = "Three notes" end
    button: click = wipe content = "Delete all" end
end

They are quick to write and poor to live with. The page freezes until the reader answers, you cannot style them, the browser may offer to block them, and a page embedded in another page is often not allowed to show them, in which case Confirm quietly answers false. Use them for a first test of an idea, and in a finished page use a dialog element (page structure) or a message inside the page like the toast above.

Components, repeat and handlers

A component is a piece of page you can use more than once, and its handlers can use the component's parameters. In the poll below, the lambda in Choice calls vote(name) with the name its own copy was made with, and the counter has an id built from the same name, so each copy counts its own votes.

web-dom-poll.salam
import dom

func vote(name: str):
    total := dom.ById("votes-" + name)
    dom.SetText(total, "" + (dom.GetText(total).to_int() + 1))
end

component Choice(name):
    box:
        class = "choice"
        button: click = (): vote(name) end content = "Vote for " + name end
        span: id = "votes-" + name content = "0" end
    end
end

page:
    title = "Poll"
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "26rem" end
    global: selector = ".choice" display = flex justify content = space-between align items = center padding = "8px 12px" margin = "0 0 8px 0" background = "#f1f5f9" border radius = 10 end
    global: selector = ".choice button" padding = "6px 14px" border = "1px solid #6366f1" border radius = 8 background = white color = "#4338ca" cursor = pointer end
    global: selector = ".choice span" font size = "1.4rem" font weight = bold color = "#0f172a" end
    heading: size = 2 content = "Your favourite drink?" margin = "0 0 12px 0" end
    Choice: name = "Tea" end
    Choice: name = "Coffee" end
    Choice: name = "Juice" end
end

Two limits are worth knowing. A lambda in a page attribute takes no event parameter, so when a handler needs the event, name a function that has one, as the seat picker does. And a plain page is fixed: repeat = 8 makes eight identical copies, but each and if need runtime values and work only in a page with parameters, which the server fills in (error E133 otherwise). That kind of page is covered in the first lesson of this part, and its ids must be different in every copy, so use a class or EventTarget there.

Putting it together: a quiz

Here is a bigger one with a little of everything. The state is three variables: which question is showing, the score, and whether the current question has been answered. The questions are not in the markup. One function returns the question and its answers as a single string, split("|") cuts it into parts, and show_card pours the parts into the page. Adding a question means adding one line.

web-dom-quiz.salam
import dom

mut asked := 0
mut score := 0
mut locked := false

func card(n: int): str:
    switch n:
        0: ret "Which element is the largest heading?|h1|h6|header|0" end
        1: ret "Which attribute gives an image its text alternative?|alt|src|title|0" end
        else: ret "Which event fires when a field changes as you type?|click|input|load|1" end
    end
    ret ""
end

func show_card:
    parts := card(asked).split("|")
    dom.SetText(dom.ById("ask"), parts.get(0))
    repeat 3 in k:
        dom.SetText(dom.ById("c" + k), parts.get(k + 1))
        dom.SetAttr(dom.ById("c" + k), "class", "choice")
    end
    dom.SetText(dom.ById("verdict"), "Pick an answer.")
    locked = false
end

func pick(k: int):
    if not locked:
        locked = true
        right := card(asked).split("|").get(4).to_int()
        if k == right:
            score += 1
            dom.AddClass(dom.ById("c" + k), "right")
            dom.SetText(dom.ById("verdict"), "Correct.")
        else:
            dom.AddClass(dom.ById("c" + k), "wrong")
            dom.AddClass(dom.ById("c" + right), "right")
            dom.SetText(dom.ById("verdict"), "Not this time.")
        end
    end
end

func onward:
    if asked == 3:
        asked = 0
        score = 0
        dom.SetText(dom.ById("next"), "Next")
        show_card()
    else locked:
        asked += 1
        if asked == 3:
            dom.SetText(dom.ById("verdict"), "You scored " + score + " of 3.")
            dom.SetText(dom.ById("next"), "Play again")
        else:
            show_card()
        end
    end
end

page:
    title = "Quiz"
    style: content = ".choice { display: block; width: 100%; margin: 6px 0; padding: 10px 14px; text-align: start; font-size: 1rem; border: 1px solid #cbd5e1; border-radius: 8px; background: white; cursor: pointer } .choice:hover { background: #eef2ff } .choice.right { background: #dcfce7; border-color: #16a34a } .choice.wrong { background: #fee2e2; border-color: #dc2626 }" end
    global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" max width = "28rem" end
    heading: id = "ask" size = 3 content = "Which element is the largest heading?" end
    button: id = "c0" class = "choice" click = (): pick(0) end content = "h1" end
    button: id = "c1" class = "choice" click = (): pick(1) end content = "h6" end
    button: id = "c2" class = "choice" click = (): pick(2) end content = "header" end
    paragraph: id = "verdict" content = "Pick an answer." color = "#475569" end
    button: id = "next" click = onward content = "Next" padding = "8px 18px" border = "0" background = "#4f46e5" color = white border radius = 8 cursor = pointer end
end

Notice the guard in pick: once a question is answered locked is true and further clicks do nothing, so nobody scores twice. Notice also that Next does nothing until an answer is chosen. Checks like these are state again, and they are cheaper than hiding and showing buttons.

The pattern for every page in this lesson is the same. Decide what the page must remember. Write one function that draws it. Make each handler change the state and call the drawing function.

When it does not work

  • Open the browser console (F12, then Console). An error in a handler appears there with its line, and nothing appears on the page. Most of the problems below are visible in it.
  • Print what you want to know. println "count is", count in a handler writes to the console, as console.log would. It is the simplest way to see whether a handler runs at all and what the state is.
  • Nothing happens on click. Check that the function name is not a name the browser already owns (reset, open, close, focus; see the note in the mouse lesson), and that there is no main in the file. A call written with parentheses, click = more(), is not a silent failure: the compiler rejects it with error E137, because the attribute wants the function itself.
  • "Cannot read properties of null". An id or selector matched nothing. Compare it with the page, and remember that ids built at run time are not checked by the compiler.
  • The page changes, then changes back. A form was sent. Call dom.PreventDefault(e) in its submit handler.
  • A list is out of date. A QueryAll result is a snapshot. Ask again after you add or remove elements.
  • Persian text is counted wrong. str.len() counts bytes in the browser. For what the reader sees use textLength as above.
mut clicks := 0

func more:
    clicks += 1
    println "clicks:", clicks
end

page:
    button: click = more content = "Click and watch the console" end
end

Everything the page does in the browser can be seen and changed by the reader. Check anything that matters, such as prices, rights and limits, again on the server. The first lesson of this part shows how a Salam program serves a page, and the dom package page lists every helper with its signature.

Try it yourself

  • Make the todo list keep its tasks: save the text of every task with StoreSetStr when the list changes, and read it back when the page opens, as the notes example does.
  • Add a fourth question to the quiz, and under the final score a line that tells the reader how many answers were right in a row.
  • Turn the stopwatch into a countdown: start from 30 seconds, stop at zero, and use dom.SetTimeout or the interval to show a message when time is up.
  • Make the accordion close the other panels when one opens, so only one is open at a time. Use QueryAll to visit them all.