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
- Finding elements, reading and writing them
- Classes, styles and attributes
- Making and removing elements
- What an event tells you
- Timers and frames
- Remembering after the page closes
- Alerts, questions and prompts
- Components, repeat and handlers
- Putting it together: a quiz
- When it does not work
- Try it yourself
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.
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
endLook 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.
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
endThe 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.
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
endA 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.
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
endSecond, 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.
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
endThe 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.
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
endWhen 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.
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
endRead 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.
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
endTimers 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.
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
endimport 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
endA 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.
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
endIf 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.
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
endThe 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.
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
endTwo 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.
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
endNotice 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", countin a handler writes to the console, asconsole.logwould. 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 nomainin 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 itssubmithandler. - A list is out of date. A
QueryAllresult 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 usetextLengthas 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
StoreSetStrwhen 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.SetTimeoutor 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
QueryAllto visit them all.