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 Source derivation pattern from State.

type location struct {
	country driver.Place
	city driver.Place
}

func LocationSelector() gox.Comp {
    // Shared state for the whole selector.
	loc := doors.NewSource(location{})
	// Derive country state.
	country := doors.DeriveSource(loc,
		// Get country from location.
		func(l location) driver.Place {
			return l.country
		},
		// Propagate country changes back to location.
		func(_ location, c driver.Place) location {
			return location{
				country: c,
			}
		},
	)
	// Derive city state.
	city := doors.DeriveSource(loc,
		// Get city from location.
		func(l location) driver.Place {
			return l.city
		},
		// Propagate city changes back to location.
		func(l location, c driver.Place) location {
			l.city = c
			return l
		},
	)
	return locationSelector{
		location: loc,
		city: city,
		country: country,
	}
}

type locationSelector struct {
	location doors.Source[location]
	country doors.Source[driver.Place]
	city doors.Source[driver.Place]
}

elem (l locationSelector) Main() {
}

The idea is simple:

  • location is the full state
  • country and city are derived read-write views 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 the title.
	title string
	options *doors.Door
	// Parameterize the search function.
	search func(input string) ([]driver.Place, error)
	selected doors.Source[driver.Place]
}

// As before.
elem (l placeSelector) Main() {
	~(l.selected.Bind(elem(p driver.Place) {
		~(if p.IsValid() {
			~(l.place(p))
		} else {
			~(l.input())
		})
	}))
}

elem (l placeSelector) place(p driver.Place) {
	~// The title comes from the field.
	<h3>~(l.title): <b>~(p.Name)</b></h3>
	<button
		(doors.AClick{
			Indicator: doors.IndicateAttr("aria-busy", "true"),
			Scope: new(doors.ScopeBlocking),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.selected.Update(ctx, driver.Place{})
				return true
			},
		})
		class="secondary">
		Change
	</button>
}


elem (l placeSelector) input() {
	~{
		// Create a unique loader id using the helper.
		loaderID := "loader-" + doors.IDString(l.title)
	}
	<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
	<input
		(doors.AInput{
            Scope: &doors.ScopeDebounce{
				Duration: 300 * time.Millisecond,
				Limit: 600 * time.Millisecond,
			},
			Indicator: doors.IndicateAttrQuery("#" + loaderID, "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				l.options.Inner(ctx, l.results(r.Event().Value))
				return false
			},
		})
		type="search"
		// Use the parameterized title as the placeholder.
		placeholder=(l.title)
		autocomplete="off"/>
	~{
		l.options.Inner(ctx, nil)
	}
	~(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>
	}
	// The search function comes from the field.
	results, _ := l.search(input)
	if len(results) == 0 {
		return <p>
			<i>nothing found</i>
		</p>
	}
	scope := new(doors.ScopeBlocking)
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					(doors.AClick{
						Scope: scope,
						PreventDefault: true,
						On: func(ctx context.Context, _ doors.RequestPointer) bool {
							l.selected.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 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,
    }
}

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,
			}
		</section>
		~// Subscribe to country results.
        ~(l.country.Bind(l.selectCity))
	</article>
}

elem (l locationSelector) selectCity(country driver.Place) {
	~// If country is set, render the 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,
			}
		</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. Focus should move to the current input automatically.

Add a tiny JavaScript helper:

// Accept an id to focus.
elem focus(id string) {
	<script data:id=(id)>
		// Read bound data.
		const id = $data("id")
		// Get the 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 a 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))
	~/* ... */
}

Location selector with focus


Next: Routing

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{})
	country := doors.DeriveSource(loc,
		func(l location) driver.Place {
			return l.country
		},
		func(_ location, c driver.Place) location {
			return location{
				country: c,
			}
		},
	)
	city := doors.DeriveSource(loc,
		func(l location) driver.Place {
			return l.city
		},
		func(l location, c driver.Place) location {
			l.city = c
			return l
		},
	)
	return locationSelector{
		location: loc,
		city: city,
		country: country,
	}
}

type locationSelector struct {
	location doors.Source[location]
	country doors.Source[driver.Place]
	city doors.Source[driver.Place]
}

elem (l locationSelector) Main() {
	<article>
		<section>
			~placeSelector{
				title: "Country",
				options: new(doors.Door),
				search: driver.Locations.CountriesSearch,
				selected: l.country,
			}
		</section>
		~(l.country.Bind(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,
			}
		</section>
	})
}

type placeSelector struct {
	title string
	options *doors.Door
	search func(input string) ([]driver.Place, error)
	selected doors.Source[driver.Place]
}

elem (l placeSelector) Main() {
	~(l.selected.Bind(elem(p driver.Place) {
		~(if p.IsValid() {
			~(l.place(p))
		} else {
			~(l.input())
		})
	}))
}

elem (l placeSelector) place(p driver.Place) {
	<h3>~(l.title): <b>~(p.Name)</b></h3>
	<button
		(doors.AClick{
			Indicator: doors.IndicateAttr("aria-busy", "true"),
			Scope: new(doors.ScopeBlocking),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.selected.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.ScopeDebounce{
				Duration: 300 * time.Millisecond,
				Limit: 600 * time.Millisecond,
			},
			Indicator: doors.IndicateAttrQuery("#" + loaderID, "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				l.options.Inner(ctx, l.results(r.Event().Value))
				return false
			},
		})
		type="search"
		placeholder=(l.title)
		autocomplete="off"/>
	~(focus(inputID))
	~{
		l.options.Inner(ctx, nil)
	}
	~(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 := new(doors.ScopeBlocking)
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					(doors.AClick{
						Scope: scope,
						PreventDefault: true,
						On: func(ctx context.Context, _ doors.RequestPointer) bool {
							l.selected.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>
}