Country Selector

Component

Here we build the first interactive piece of the app: a country search.

Plan:

  1. Define a countrySelector component with a dynamic container
  2. Listen to input events from the search field
  3. Render matching countries into the dynamic container

This step introduces Door plus input and click hooks from Events.

./country_selector.gox

package main

import (
	"context"

	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

type countrySelector struct {
	// dynamic container for search results
	options *doors.Door
}

// component main render function
elem (l countrySelector) Main() {
	<h3>Select Country</h3>
	<input
		// attach input listener
		(doors.AInput{
			On: func(ctx context.Context, r doors.RequestInput) bool {
				// read the current input value
				input := r.Event().Value
				// update dynamic container with the new results
				l.options.Update(ctx, l.results(input))
				return false
			},
		})
		type="search"
		placeholder="Country"
		autocomplete="off"/>

	~// render dynamic container
	~(l.options)
}

// here a regular function is more convenient than
// the elem primitive
func (l countrySelector) 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, _ := driver.Locations.CountriesSearch(input)
	if len(results) == 0 {
		return <p>
			<i>nothing found</i>
		</p>
	}
	return <ul>
		~(for _, place := range results {
			<li>
				~(place.Name)
			</li>
		})
	</ul>
}

And render it on the page:

Render

./app.gox

elem (a App) Main() {
	<!doctype html>
	<html lang="en">
		<head>
			~/* head content */
		</head>
		<body>
			<main class="container">
				~// our new component
				~countrySelector{
					options: new(doors.Door),
				}
			</main>
		</body>
	</html>
}

At this point, typing in the input triggers a request, and the search results area is re-rendered with the new matches.

Result:

Country search results

Debounce

This works, but sending a request on every keystroke is wasteful.

Add debounce with the Scopes API:

See Scopes for the full model.

<input
	(doors.AInput{
		// use ScopeOnly* helper to create a basic scope setup:
		// wait 300 milliseconds after the last input before sending
		// but not more than 600 milliseconds since the first
		Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
		On: func(ctx context.Context, r doors.RequestInput) bool {
			l.options.Update(ctx, l.results(r.Event().Value))
			return false
		},
	})
	type="search"
	placeholder="Country"
	autocomplete="off"/>

The Scopes API controls what happens when events overlap. Scopes run on the client, before the backend request begins, so they are the right tool for interaction policy like debouncing or preventing double-clicks.

Indication

The next problem is feedback. Searching is fast locally, but real responses are not instant, so the user should see that something is happening.

PicoCSS already has an aria-busy pattern for this. Doors can toggle it through the Indication API:

See Indication for the indicator helpers and selector variants.

elem (l countrySelector) Main() {
	~// add loader element target
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
			Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
			// use IndicatorOnly* helper to create a basic indication setup:
			// apply attribute aria-busy="true" to the element #search-loader
			Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "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="Country"
		autocomplete="off"/>
	~(l.options)
}

Debounce and indication together (simulated latency):

Country search with debounce and loading state

The indication clears after all hook-triggered changes apply on the client.

State

Country and city selection can use the dynamic container API alone, without any reactive state.

However, in multi-step forms and more complex UIs, that lower-level approach spreads logic across handlers and makes the flow harder to read and debug.

A single source of truth makes the component easier to reason about.

For Source, derived values, and subscriptions, see State.

Add State

Add a field for the selected country:

type countrySelector struct {
	options *doors.Door
	// reactive state source
	selected doors.Source[driver.Place]
}

Now it makes sense to create the selector through a constructor:

func CountrySelector() gox.Comp {
	return countrySelector{
		options:  new(doors.Door),
		selected: doors.NewSource(driver.Place{}),
	}
}

Also update the page template:

app.gox

<main class="container">
	~(CountrySelector())
</main>

Update

Now update the selected state when the user clicks a search result:

func (l countrySelector) results(input string) gox.Elem {
	/* ... */
	// create shared scope
	scope := doors.ScopeOnlyBlocking()
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					// attach click
					(doors.AClick{
						// use shared scope on all options
						Scope: scope,
						// prevent link default behavior
						PreventDefault: true,
						On: func(ctx context.Context, _ doors.RequestPointer) bool {
							// update state with the selected country
							l.selected.Update(ctx, place)
							// done, remove hook
							return true
						},
					})
					href="#">
					~(place.Name)
				</a>
			</li>
		})
	</ul>
}

We use the same scope set for all search options, so all clicks pass through one pipeline and only one can be in flight at a time. Strictly speaking, it will not change much here, but it is still a good practice. It avoids unnecessary requests and indication, which gives the UI a more polished feel.

Subscribe

Then subscribe the main render function to state changes:

elem (l countrySelector) Main() {
	~// subscribe region to state changes, provide on change handler
	~// that will return element to render
	~(doors.Sub(l.selected, elem(p driver.Place) {
		~(if p.IsValid() {
			<h3>Country: <b>~(p.Name)</b></h3>
			~// clear country selection
			<button
				(doors.AClick{
					// indicate on the button
					Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
					// block repeated clicks
					Scope: doors.ScopeOnlyBlocking(),
					On: func(ctx context.Context, _ doors.RequestPointer) bool {
						// clear the selection
						l.selected.Update(ctx, driver.Place{})
						// hook is done, remove it
						return true
					},
				})
				class="secondary">
				Change
			</button>
		} else {
			<h3>Select Country <span id="search-loader"></span></h3>
			<input
				(doors.AInput{
					Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
					// use IndicatorOnly* helper to create basic indication setup:
					// apply attribute aria-busy="true" to the element #search-loader
					Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "aria-busy", "true"),
					On: func(ctx context.Context, r doors.RequestInput) bool {
						l.options.Update(ctx, l.results(r.Event().Value))
						// keep hook active
						return false
					},
				})
				type="search"
				placeholder="Country"
				autocomplete="off"/>
			~(l.options)
		})
	}))
}

This version already works, but the Main function is starting to do too much.

Refactor

doors.Sub can itself be rendered as a component, so we can make Main() smaller:

func (l countrySelector) 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()
}

Then move each state into its own view:

elem (l countrySelector) place(p driver.Place) {
	<h3>Country: <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.selected.Update(ctx, driver.Place{})
				return true
			},
		})
		class="secondary">
		Change
	</button>
}

elem (l countrySelector) input() {
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
			Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
			Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "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="Country"
		autocomplete="off"/>
	~(l.options)
}

Let's see how selection works with reactive state:

Country selection with reactive state

Search results were not cleared. That makes sense: we never cleared them.

elem (l countrySelector) input() {
	~/* ... */
	~{
		l.options.Clear(ctx)
	}
	~(l.options)
}

A dynamic container has its own lifecycle. When we render the selected country instead of the search results, we just unmount it, so we need to clear it before rendering again.

Result:

Country selection after clearing previous results


Next: Location Selector

Code

country_selector.gox

package main

import (
	"context"
	"time"
	
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

func CountrySelector() gox.Comp {
	return countrySelector{
		options: new(doors.Door),
		selected: doors.NewSource(driver.Place{}),
	}
}

type countrySelector struct {
	options *doors.Door
	selected doors.Source[driver.Place]
}

func (l countrySelector) 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 countrySelector) place(p driver.Place) {
	<h3>Country: <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.selected.Update(ctx, driver.Place{})
				return true
			},
		})
		class="secondary">
		Change
	</button>
}

elem (l countrySelector) input() {
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
			Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
			Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "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="Country"
		aria-label="Country"
		autocomplete="off"/>
	~{
		l.options.Clear(ctx)
	}
	~(l.options)
}

func (l countrySelector) 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, _ := driver.Locations.CountriesSearch(input)
	if len(results) == 0 {
		return <p>
			<i>nothing found</i>
		</p>
	}
	scope := doors.ScopeOnlyBlocking()
	return <ul>
		~(for _, place := range results {
			<li
				(doors.AClick{
					Scope: scope,
					PreventDefault: true,
					On: func(ctx context.Context, _ doors.RequestPointer) bool {
						l.selected.Update(ctx, place)
						return true
					},
				})>
				<a href="#">~(place.Name)</a>
			</li>
		})
	</ul>
}

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">
				~(CountrySelector())
			</main>
		</body>
	</html>
}