Search

Search lets people navigate a product with a query. The search bar and the view that shows its results are one component.

Usage

A search bar has a leading search icon, hinted search text and up to two trailing icons you add. Put the suggestions or results inside as children, in a List. They show when the field has focus. The view floats over the page instead of pushing it down, and closes when focus leaves it. Enter reports the query, keeps it visible and moves focus to the results.

<script lang="ts">
	import { List, ListItem, Search } from 'noph-ui'

	const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']

	let query = $state('')
	let expanded = $state(false)

	const matches = (q: string) =>
		q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>

<div style="width:26rem;max-width:100%">
	<Search bind:value={query} bind:expanded placeholder="Search product">
		<List aria-label="Suggestions">
			{#each matches(query) as dish (dish)}
				<ListItem
					onclick={() => {
						query = dish
						expanded = false
					}}
				>
					{dish}
				</ListItem>
			{/each}
		</List>
	</Search>
</div>

Semantics

Build suggestions and results with List and ListItem, so screen readers announce them as a list. The container around them has no role, so it can also hold category labels, avatars or chips next to the list. The field is a plain search field. ↓ moves into the results, the arrow keys move between them, and ↑ on the first one goes back to the field.

For options that people step through while focus stays in the field, use autocomplete. It is a full combobox and handles the active option and aria-activedescendant.

Styles

contained, the default, keeps the field's pill shape and colour while the results show. Material recommends it. divided gives the field square corners and separates it from the results with a line.

<script lang="ts">
	import { List, ListItem, Search } from 'noph-ui'

	const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']

	let dividedQuery = $state('')
	let expanded = $state(false)

	const matches = (q: string) =>
		q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>

<div style="width:26rem;max-width:100%">
	<Search bind:value={dividedQuery} bind:expanded variant="divided" placeholder="Search product">
		<List aria-label="Suggestions">
			{#each matches(dividedQuery) as dish (dish)}
				<ListItem
					onclick={() => {
						dividedQuery = dish
						expanded = false
					}}
				>
					{dish}
				</ListItem>
			{/each}
		</List>
	</Search>
</div>

Trailing icons

The trailing slot takes an avatar or actions. A clear button appears next to it when there is a query.

<script lang="ts">
	import { IconButton, List, ListItem, Search } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	let expanded = $state(false)
</script>

<div style="width:26rem;max-width:100%">
	<Search bind:expanded placeholder="Search product">
		{#snippet trailing()}
			<IconButton title="Filter"><Icon>settings</Icon></IconButton>
		{/snippet}
		<List aria-label="Suggestions">
			<ListItem type="button" onclick={() => (expanded = false)}>Simple Classic Tacos</ListItem>
		</List>
	</Search>
</div>

Layout

docked shows the results under the field. The open view is at least 240px and at most two thirds of the screen high. full-screen fills the viewport when the field has focus. Without a view, search is full screen in windows below 600px and docked above.

<script lang="ts">
	import { List, ListItem, Search } from 'noph-ui'

	const dishes = ['Simple Classic Tacos', 'Mexican street corn', 'Chilaquiles verdes']

	let fullScreenQuery = $state('')
	let expanded = $state(false)

	const matches = (q: string) =>
		q ? dishes.filter((d) => d.toLowerCase().includes(q.toLowerCase())) : dishes
</script>

<div style="width:26rem;max-width:100%">
	<Search
		bind:value={fullScreenQuery}
		bind:expanded
		view="full-screen"
		placeholder="Search product"
	>
		<List aria-label="Suggestions">
			{#each matches(fullScreenQuery) as dish (dish)}
				<ListItem
					onclick={() => {
						fullScreenQuery = dish
						expanded = false
					}}
				>
					{dish}
				</ListItem>
			{/each}
		</List>
	</Search>
</div>

Motion

The search bar grows when it takes focus. Its margin to the pane shrinks from 24dp to 12dp. Change the distance with the two custom properties. Set both to the same value to keep the bar still.

<script lang="ts">
	import { List, ListItem, Search } from 'noph-ui'

	let expanded = $state(false)
</script>

<div style="width:26rem;max-width:100%">
	<Search
		bind:expanded
		placeholder="Search product"
		--np-search-pane-margin="4rem"
		--np-search-view-margin="0rem"
	>
		<List aria-label="Suggestions">
			<ListItem type="button" onclick={() => (expanded = false)}>Simple Classic Tacos</ListItem>
		</List>
	</Search>
</div>

Search app bar

Use a search app bar as an emphasised, global entry point. It is the app bar's search variant, which shows a field instead of a headline.

<AppBar variant="search">
	{#snippet leading()}
		<IconButton title="Open navigation"><Icon>menu</Icon></IconButton>
	{/snippet}
	{#snippet search()}
		<Search bind:value={query} placeholder="Search product" />
	{/snippet}
</AppBar>

Opening it yourself

Focusing the field opens the view. When the bar has no room at rest, for example in a narrow app bar, open it from another control with show. close closes it. Both handle the caret. show adds the bar to the document before it moves focus there, so a bar that is hidden until it opens still works. Call show directly in the tap handler, so Safari on iOS opens the keyboard.

<script lang="ts">
	import { IconButton, List, ListItem, Search } from 'noph-ui'
	import { Icon } from 'noph-ui/icons'

	let search: ReturnType<typeof Search> | undefined = $state()
	let expanded = $state(false)
</script>

<IconButton title="Open search" onclick={() => search?.show()}>
	<Icon>search</Icon>
</IconButton>

<!-- The bar has nowhere to sit until it opens, so it is out of the layout while at rest. -->
<div class={['view', !expanded && 'resting']}>
	<Search bind:this={search} bind:expanded view="full-screen" placeholder="Search product">
		<List aria-label="Suggestions">
			<ListItem type="button" onclick={() => (expanded = false)}>Simple Classic Tacos</ListItem>
		</List>
	</Search>
</div>

<style>
	.view.resting {
		display: none;
	}
</style>

Theming

Custom propertyDescription
--np-search-container-colorBackground of the field and results.
--np-search-view-background-colorBackground behind a full screen contained view.
--np-search-shapeCorner radius of the field.
--np-search-widthMaximum width of the field. 720dp.
--np-search-pane-marginSpace the field keeps to its pane at rest. 24dp.
--np-search-view-marginThe same space once the view is open. 12dp.
--np-search-results-shapeCorner radius of the docked results.
--np-search-docked-min-heightMinimum height of the open docked view with results, field included. 240dp.
--np-search-docked-max-heightMaximum height of the open docked view. Two thirds of the screen.
--np-search-results-max-heightHow tall the docked results grow.
--np-search-z-indexHow the open view stacks over the page.
--np-search-divider-colorThe line under a divided field.
--np-search-focus-indicator-colorThe focus ring.

Accessibility

The field is a native <input type="search"> named by its hinted text, the placeholder, or by label where that should differ. The clear button and the back arrow are named by clearLabel and backLabel, so all of them can be translated. When results appear or change, a polite status tells screen readers how many there are. resultsAnnouncement sets the text.

Focus opens the view, and moving focus out of it closes it. Escape closes it from the field or the results and keeps the query. See Semantics for the keyboard in the results.

API

Renders a <div> holding the field and its results, and takes its attributes. bind:element gives you that wrapper and bind:inputElement the input.

Methods

MethodDescription
show()Opens the view and moves focus into the field before it returns.
close()Closes the view and takes focus off the field.

Attributes

AttributeTypeDefaultDescription
valuestring''Bindable. The query.
placeholderstring''Hinted search text.
expandedbooleanfalseBindable. Whether the results show. Focusing the field opens them.
variant'contained' | 'divided''contained'Whether the field keeps its pill while the results show.
view'docked' | 'full-screen' | undefinedundefinedResults under the field, or covering the viewport. Without one, full screen below 600px and docked above.
leadingSnippet | undefinedundefinedReplaces the search icon and the back arrow shown while expanded.
trailingSnippet | undefinedundefinedAvatar or actions, two at most. The clear button sits beside it in the open view.
onsearch(value: string) => voidundefinedCalled on the Enter key with the current query.
labelstringplaceholderAccessible name for the field. Defaults to the hinted text.
resultsAnnouncement(count: number) => string'3 results'What screen readers hear when results appear or change.
clearLabelstring'Clear search'Accessible name for the clear button.
backLabelstring'Close search'Accessible name for the back action shown while expanded.
inputAttributesHTMLInputAttributes | undefinedundefinedExtra attributes for the underlying input.
resultsAttributesHTMLAttributes<HTMLDivElement> | undefinedundefinedExtra attributes for the results container, including its role.