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
}

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

	~// Render the 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{
		// Wait 300 milliseconds after the last input before sending,
		// but no more than 600 milliseconds after the first input.
        Scope: &doors.ScopeDebounce{
            Duration: 300 * time.Millisecond,
            Limit: 600 * time.Millisecond,
        },
		On: func(ctx context.Context, r doors.RequestInput) bool {
			l.options.Inner(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 a loader element target.
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
            Scope: &doors.ScopeDebounce{
                Duration: 300 * time.Millisecond,
                Limit: 600 * time.Millisecond,
            },
			// Apply aria-busy="true" to the #search-loader element.
            Indicator: doors.IndicateAttrQuery("#search-loader", "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="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 a shared scope.
	scope := new(doors.ScopeBlocking)
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					// Attach the click handler.
					(doors.AClick{
						// Use the shared scope on all options.
						Scope: scope,
						// Prevent the link's 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 this 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.

Bind

Then subscribe the main render function to state changes:

elem (l countrySelector) Main() {
	~// Subscribe to state changes and provide an on-change handler
	~// that returns the element to render.
    ~(l.selected.Bind(elem(p driver.Place) {
		~(if p.IsValid() {
			~(l.place(p))
		} else {
			~(l.input())
		})
	}))
}

elem (l countrySelector) place(p driver.Place) {
	<h3>Country: <b>~(p.Name)</b></h3>
	~// Clear the country selection.
	<button
		(doors.AClick{
			// Show indication on the button.
			Indicator: doors.IndicateAttr("aria-busy", "true"),
			// Block repeated clicks.
			Scope: new(doors.ScopeBlocking),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				// Clear the selection.
				l.selected.Update(ctx, driver.Place{})
				// The hook is done; remove it.
				return true
			},
		})
		class="secondary">
		Change
	</button>
}

elem (l countrySelector) input() {
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
			Scope: &doors.ScopeDebounce{
				Duration: 300 * time.Millisecond,
				Limit: 600 * time.Millisecond,
			},
			Indicator: doors.IndicateAttrQuery("#search-loader", "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				input := r.Event().Value
				l.options.Inner(ctx, l.results(input))
				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.Inner(ctx, nil)
	}
	~(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]
}

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

elem (l countrySelector) input() {
	<h3>Select Country <span id="search-loader"></span></h3>
	<input
		(doors.AInput{
			Scope: &doors.ScopeDebounce{
				Duration: 300 * time.Millisecond,
				Limit: 600 * time.Millisecond,
			},
			Indicator: doors.IndicateAttrQuery("#search-loader", "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				input := r.Event().Value
				l.options.Inner(ctx, l.results(input))
				return false
			},
		})
		type="search"
		placeholder="Country"
		autocomplete="off"/>
	~{
		l.options.Inner(ctx, nil)
	}
	~(l.options)
}

elem (l countrySelector) place(p driver.Place) {
	<h3>Country: <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>
}

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

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