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 Source and Beam pattern 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:

  • location is the full state
  • country and city are beams derived from that shared state
  • locationSelector owns 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:

Country and city selector with shared state

Bonus

The form works, but the UX can still be better.

Two things are missing:

  1. focus should move to the current input automatically
  2. 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 enables await.

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:

Location selector with focus and keyboard support


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>
}