Location Selector
For the weather dashboard, we need both a country selector and a city selector.
At this point it makes sense to introduce shared state for the whole flow and reuse the selector UI instead of maintaining two separate components.
State
Start with a top-level component that owns the shared state.
This is the core
SourceandBeampattern from State.
// shared state type
type location struct {
country driver.Place
city driver.Place
}
func LocationSelector() gox.Comp {
// shared state
loc := doors.NewSource(location{})
// derived city state
city := doors.NewBeam(loc, func(l location) driver.Place {
return l.city
})
// derived country state
country := doors.NewBeam(loc, func(l location) driver.Place {
return l.country
})
return locationSelector{
location: loc,
city: city,
country: country,
}
}
type locationSelector struct {
location doors.Source[location]
country doors.Beam[driver.Place]
city doors.Beam[driver.Place]
}
elem (l locationSelector) Main() {
}
The idea is simple:
locationis the full statecountryandcityare beams derived from that shared statelocationSelectorowns the flow, while child selectors read and update only the part they need
Reuse
Now convert the country selector into a universal place selector.
type placeSelector struct {
// parameterize title
title string
options *doors.Door
// parameterize search function
search func(input string) ([]driver.Place, error)
// accept derived state as dependency
selected doors.Beam[driver.Place]
// callback to update external state
update func(ctx context.Context, place driver.Place)
}
// as-is
func (l placeSelector) Main() gox.Elem {
return doors.Sub(l.selected, func(p driver.Place) gox.Elem {
if p.IsValid() {
return l.place(p)
}
return l.input()
}).Main()
}
elem (l placeSelector) place(p driver.Place) {
~// title comes from field
<h3>~(l.title): <b>~(p.Name)</b></h3>
<button
(doors.AClick{
Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
Scope: doors.ScopeOnlyBlocking(),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
~// use provided update callback to change state
l.update(ctx, driver.Place{})
return true
},
})
class="secondary">
Change
</button>
}
elem (l placeSelector) input() {
~{
// create unique id for loader using helper
loaderID := "loader-" + doors.IDString(l.title)
}
<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
<input
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
Indicator: doors.IndicatorOnlyAttrQuery("#" + loaderID, "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
// use parameterized title as placeholder
placeholder=(l.title)
autocomplete="off"/>
~{
l.options.Clear(ctx)
}
~(l.options)
}
func (l placeSelector) results(input string) gox.Elem {
if len(input) == 0 {
return nil
}
if len(input) < 2 {
return <p>
<mark>Type at least two letters to search</mark>
</p>
}
results, _ := l.search(input)
if len(results) == 0 {
return <p>
<i>nothing found</i>
</p>
}
scope := doors.ScopeOnlyBlocking()
return <ul>
~(for _, place := range results {
<li>
<a
(doors.AClick{
PreventDefault: true,
Scope: scope,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.update(ctx, place)
return true
},
})
href="#">
~(place.Name)
</a>
</li>
})
</ul>
}
The structure stays the same as the old country selector. The difference is that title, search function, selected state, and update logic are now injected from outside.
Flow
Now render the reusable selector inside locationSelector:
elem (l locationSelector) Main() {
~placeSelector{
title: "Country",
options: new(doors.Door),
search: driver.Locations.CountriesSearch,
selected: l.country,
update: func(ctx context.Context, place driver.Place) {
l.location.Update(ctx, location{
country: place,
})
},
}
}
And update the page template:
<body>
<main class="container">
~(LocationSelector())
</main>
</body>
At this point, it should behave exactly like the previous country selector.
Now add city selection:
elem (l locationSelector) Main() {
<article>
<section>
~placeSelector{
title: "Country",
options: new(doors.Door),
search: driver.Locations.CountriesSearch,
selected: l.country,
update: func(ctx context.Context, place driver.Place) {
l.location.Update(ctx, location{
country: place,
})
},
}
</section>
~// subscribe to country results
~(doors.Sub(l.country, l.selectCity))
</article>
}
elem (l locationSelector) selectCity(country driver.Place) {
~// if country is set, render city selector
~(if country.IsValid() {
<section>
~placeSelector{
title: "City",
options: new(doors.Door),
search: func(input string) ([]driver.Place, error) {
return driver.Locations.CitiesSearch(country.Id, input)
},
selected: l.city,
update: func(ctx context.Context, place driver.Place) {
// mutate previous state with city setup
l.location.Mutate(ctx, func(l location) location {
l.city = place
return l
})
},
}
</section>
})
}
Now the whole flow is coordinated through one shared state object.
Dynamic form with reactive state:

Bonus
The form works, but the UX can still be better.
Two things are missing:
- focus should move to the current input automatically
- flow should work well with the keyboard
Focus
Add a tiny JavaScript helper:
// accept id to focus
elem focus(id string) {
<script data:id=(id)>
// read bound data
const id = $data("id")
// get element and focus it
const el = document.getElementById(id)
el.focus()
</script>
}
Doors converts inline script into a minified, cacheable script with
src, wraps it in an anonymous function, and enablesawait.
For more on inline scripts and resource-backed JS, see JavaScript.
Render it after the input:
elem (l placeSelector) input() {
~{
loaderID := "loader-" + doors.IDString(l.title)
// also prepare unique id for the input
inputID := "input-" + doors.IDString(l.title)
}
<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
<input
id=(inputID)
/* ... */>
~(focus(inputID))
~/* ... */
}
Keyboard
Attach a keyboard event hook to each search result:
Keyboard handlers use the same Events API as the earlier click and input hooks.
<li>
<a
(doors.AClick{
PreventDefault: true,
Scope: scope,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.update(ctx, place)
return true
},
}, doors.AKeyDown{
Scope: scope,
Filter: []string{"Enter"},
On: func(ctx context.Context, _ doors.RequestKeyboard) bool {
l.update(ctx, place)
return true
},
})
href="#">
~(place.Name)
</a>
</li>
Keyboard control enabled:

Next: Path and Title
Code
./location_selector.gox
package main
import (
"context"
"time"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
type location struct {
country driver.Place
city driver.Place
}
func LocationSelector() gox.Comp {
loc := doors.NewSource(location{})
city := doors.NewBeam(loc, func(l location) driver.Place {
return l.city
})
country := doors.NewBeam(loc, func(l location) driver.Place {
return l.country
})
return locationSelector{
location: loc,
city: city,
country: country,
}
}
type locationSelector struct {
location doors.Source[location]
country doors.Beam[driver.Place]
city doors.Beam[driver.Place]
}
elem (l locationSelector) Main() {
<article>
<section>
~placeSelector{
title: "Country",
options: new(doors.Door),
search: driver.Locations.CountriesSearch,
selected: l.country,
update: func(ctx context.Context, place driver.Place) {
l.location.Update(ctx, location{
country: place,
})
},
}
</section>
~(doors.Sub(l.country, l.selectCity))
</article>
}
elem (l locationSelector) selectCity(country driver.Place) {
~(if country.IsValid() {
<section>
~placeSelector{
title: "City",
options: new(doors.Door),
search: func(input string) ([]driver.Place, error) {
return driver.Locations.CitiesSearch(country.Id, input)
},
selected: l.city,
update: func(ctx context.Context, place driver.Place) {
l.location.Mutate(ctx, func(l location) location {
l.city = place
return l
})
},
}
</section>
})
}
type placeSelector struct {
title string
options *doors.Door
search func(input string) ([]driver.Place, error)
selected doors.Beam[driver.Place]
update func(ctx context.Context, place driver.Place)
}
func (l placeSelector) Main() gox.Elem {
return doors.Sub(l.selected, func(p driver.Place) gox.Elem {
if p.IsValid() {
return l.place(p)
}
return l.input()
}).Main()
}
elem (l placeSelector) place(p driver.Place) {
<h3>~(l.title): <b>~(p.Name)</b></h3>
<button
(doors.AClick{
Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
Scope: doors.ScopeOnlyBlocking(),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.update(ctx, driver.Place{})
return true
},
})
class="secondary">
Change
</button>
}
elem (l placeSelector) input() {
~{
loaderID := "loader-" + doors.IDString(l.title)
inputID := "input-" + doors.IDString(l.title)
}
<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
<input
id=(inputID)
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
Indicator: doors.IndicatorOnlyAttrQuery("#" + loaderID, "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder=(l.title)
autocomplete="off"/>
~(focus(inputID))
~{
l.options.Clear(ctx)
}
~(l.options)
}
func (l placeSelector) results(input string) gox.Elem {
if len(input) == 0 {
return nil
}
if len(input) < 2 {
return <p>
<mark>Type at least two letters to search</mark>
</p>
}
results, _ := l.search(input)
if len(results) == 0 {
return <p>
<i>nothing found</i>
</p>
}
scope := doors.ScopeOnlyBlocking()
return <ul>
~(for _, place := range results {
<li>
<a
(doors.AClick{
PreventDefault: true,
Scope: scope,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.update(ctx, place)
return true
},
}, doors.AKeyDown{
Scope: scope,
Filter: []string{"Enter"},
On: func(ctx context.Context, _ doors.RequestKeyboard) bool {
l.update(ctx, place)
return true
},
})
href="#">
~(place.Name)
</a>
</li>
})
</ul>
}
elem focus(id string) {
<script data:id=(id)>
const id = $data("id")
const el = document.getElementById(id)
el.focus()
</script>
}
app.gox
package main
import (
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
)
type App struct{}
elem (a App) Main() {
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hello Doors!</title>
<link
(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
rel="stylesheet">
</head>
<body>
<main class="container">
~(LocationSelector())
</main>
</body>
</html>
}