Search (searchable)

Search is declared on the surface, not on the toolbar.

nav(section)
    .style(NavStyle::Sidebar)
    .searchable(query)                   // Signal<String>, two-way
    .search_prompt(tr("search-sections"))
    .items(move || destinations_matching(query.get()), row)

That decision makes the rest work. Because the surface owns the declaration and the app owns the Signal, the toolkit is free to draw the field wherever its platform puts search (in the window toolbar on a wide window, attached to the navigation list on a narrow one), and moving it never moves the state. The app filters its own rows from its own signal either way, and never branches on where the field went.

This is the same move SwiftUI made with .searchable(), for the same reason.

The modifiers

modifierwhat it does
.searchable(query)makes the surface searchable, bound two-way to query
.search_prompt(text)the empty-state prompt
.search_placement(p)a placement preference (see below)
.search_scopes(scope, titles)a one-of-N scope bar, bound to scope
.search_suggestions(f)completions for the current text

Placement is a preference

SearchPlacement::{Automatic, Toolbar, Inline} states a preference. A backend that cannot honor the request falls back to its platform’s convention, as SwiftUI documents (“depending on the containing view hierarchy and platform, the requested placement may not be able to be fulfilled”). Automatic is almost always the right answer: it lets the field live in the toolbar on a desktop window and move into the navigation list on a phone.

Automatic resolves to the window toolbar wherever the toolkit has one, and to Inline where it does not. That second case needs no size class, because “this toolkit has no toolbar at all” is a static fact about the backend rather than a question about the window’s width.

On iOS the two placements name one surface, so the resolution does not matter there: UINavigationItem owns the navigation bar’s buttons AND its search controller, and the window toolbar rides that same item (docs/toolbars.md) — so day-uikit installs the field whichever way it was asked for. It has to: the day iOS gained Cap::Toolbar (2026-09), every .searchable() surface there resolved to Toolbar and the field silently vanished, because a UIBarButtonItem cannot be a search field and the toolbar builder skipped it.

Inline, per platform

Each platform keeps its own convention.

backendwhereresting statehow it is revealed
ios-uikitUISearchController on the root page’s navigationItemvisible, pinned under the titlealways there (hidesSearchBarWhenScrolling off)
android-mdcMaterial SearchBar above the nav listvisible, scroll-awayalways there; returns on scroll up
harmony-arkuiArkUI Search atop the nav listvisiblealways there

iOS PINS the field rather than hiding it behind a pull-down (2026-09). The hide is a phone idiom — a list that owns the whole screen can trade the field for a row of content — and a sidebar is not that: it is a narrow permanent column beside the detail it filters, and a field you have to know to pull for is one nobody finds. Deciding it per presentation would mean asking isCollapsed, which answers nothing on a host that has not met a window yet and never changes again on a device that only ever has one shape, so the field’s presence would turn on whether a rotation happened to fire. One rule at both sizes, with a large title above it — a standard iOS configuration either way.

Android has no hidesSearchBarWhenScrolling equivalent. Material’s SearchBar supports fixed / scroll-away / lift-on-scroll through CoordinatorLayout behaviors, but scroll-away hides on scroll down and returns on scroll up; there is no pull-past-the-top reveal. Forcing the iOS gesture onto Android would make a Day app the only one on the device that behaves that way.

Day uses SearchBar without expanding it into Material’s full-screen SearchView. SearchView is an overlay showing its own results list, and on a searchable navigation surface the list underneath is already the result set, so the overlay would mean maintaining a second copy of it.

One model, one writer

SearchProps on the nav host holds the state for every placement. The toolbar item a desktop backend draws is a rendering of it and carries no state of its own: both inbound transports (a toolbar value callback, or Event::SearchChanged from an inline field) write the app’s Signal, and one outbound binding patches whichever target the resolved placement renders into.

That will make a placement change tractable. The state does not live in the widget, so re-rendering into the other target is a patch rather than a rebuild, the same property that makes the navigation host re-presentable. The remaining step for the size-class work is a SearchPatch::Placement that swaps the render target on a live host; until it exists, placement is resolved once at realize and cannot change (docs/navigation.md).

How the toolbar placement is realized

Day hands the resolved field to day-core as an ordinary ToolbarItemKind::Search item, which is merged into the window’s bar under the reserved id day.search, trailing. This has two consequences:

  • every backend that already drew a toolbar search field draws this one, with no per-backend code;
  • the merge happens inside set_window_toolbar, not at the app’s call site, because the app installs its bar before the tree builds and a derived contribution re-lowers on any reactive change. An item injected once would be dropped by the next rebuild.

dayscript addresses the field by that id: toolbar: { item: day.search, text: "…" }.

Scopes are not drawn yet

.search_scopes() is in the API and lowers correctly, but no backend draws a scope bar yet, because a scope bar has nowhere to live at the Toolbar placement: it belongs below the field, and Day’s toolbar model is a flat list of items with no “below the bar” slot. The field currently sits inside an NSSearchToolbarItem / an AdwHeaderBar entry / a QToolBar widget, none of which can host one.

Scopes therefore wait on the Inline placement, where the field is attached to the navigation surface and the bar can sit under it. When they land, the per-backend mapping is:

backendscope barCap::SearchScopes
ios-uikitUISearchBar.scopeButtonTitlesNative
android-mdcChipGroup of single-selection filter chipsEmulated
harmony-arkuiSegmentButtonV2Emulated
macos-appkitNSSegmentedControlEmulated
linux-gtk.linked toggle buttonsEmulated
linux-qta button rowEmulated
web-doma radio groupEmulated
windows-xamla ToggleButton row (system XAML has no Segmented)Emulated

Emulated covers two different situations there: a real native component doing this job (the chips, SegmentButtonV2, NSSegmentedControl) and a bar composed from primitives (web, XAML). Neither claims to be the platform’s scope bar.

Suggestions complete the field

On a navigation surface the list is the result set. So .search_suggestions() offers completions that fill the field; it does not open an overlay of results, which would cover the list it is narrowing.

Day uses the search widget’s own completion affordance, and only that: a completion popup Day drew itself would not match the platform’s keyboard handling or styling, and a search field that behaves differently from every other one on the system is worse than no completions.

backendcompletions
linux-qtQCompleter (native popup, case-insensitive)
web-dom<datalist> (the browser’s own popup)
windows-xamlAutoSuggestBox.ItemsSource
ios-uikitUISearchResultsUpdating, at any placement — see below
macos-appkitnone. NSSearchField’s menu is a recents list, not completions for the current text, so Day does not present it as one
linux-gtknone. GTK4 deprecated GtkEntryCompletion and GtkSearchEntry has no replacement
android-mdc, harmony-arkuiwith the Inline placement

Choosing a suggestion puts it in the field and emits the same change any keystroke would, so an app that only reads query needs no extra handling. Event::SearchSuggestionChosen says which one, for an app that wants to act on the choice itself.

What is searchable

Nav today. A nav_stack gains the same surface when the placement resolver lands, since it is the same lowering. Search on arbitrary page content is out of scope, because every backend would need a placement answer for content that is not navigation chrome.

Two-way, through the signal

Both directions go through the app’s Signal, never through the widget:

  • the user typing emits Event::SearchChanged, which writes the signal;
  • the app writing the signal patches the live field (SearchPatch::Text) rather than rebuilding it, so a sync never steals focus or resets the insertion point mid-word.

That discipline will let the field change placement later without the query noticing.