<p align="center">
  <img title="v1.0.0 Screenshot" src="https://raw.githubusercontent.com/chrislaskey/phoenix_select/refs/heads/main/examples/screenshot-v1.0.0.png" width="600">
</p>

# Phoenix Select

> An accessible select, multi-select, and searchable combobox for Phoenix
LiveView with no external JS dependencies. Simple installation that's easy to
maintain.

## Demo

The [`/examples`](examples/) directory contains many examples, including a full
Phoenix demo app that demonstrates the different options:

```
git clone https://github.com/chrislaskey/phoenix_select.git
cd phoenix_select/examples/demo
mix setup && iex -S mix phx.server
```

Note: the script `examples/regenerate.sh` can be used to regenerate the demo from a pinned
`phx.new` release.

## Requirements

Phoenix Select requires Phoenix LiveView `v1.1` or greater. Specifically:

```
{:phoenix_live_view, ">= 1.1.0"},
{:phoenix_html, ">= 4.0.0"},
{:plug, ">= 1.14.0"},
```

The library leverages features released in `v1.1` to make installation and future upgrades even easier.

## Installation

The dropdown renders on the server. A single colocated hook (shipped inside
the library, extracted at compile time by LiveView 1.1+) adds what markup
alone cannot: keyboard navigation following the WAI-ARIA combobox pattern,
client-side filtering, and focus management. There is no npm package to
install and no file to vendor — the JS upgrades atomically with the Hex
package.

```elixir
def deps do
  [
    {:phoenix_select, github: "chrislaskey/phoenix_select"}
  ]
end
```

Requires LiveView `~> 1.1` and Phoenix 1.8+ (for colocated hook support).

Register the hooks in `assets/js/app.js` — this is the entire JS setup:

```javascript
import {hooks as phoenixSelectHooks} from "phoenix-colocated/phoenix_select"

const liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...colocatedHooks, ...phoenixSelectHooks},
  // ...
})
```

If you use Tailwind, add the library to your `@source` paths in `app.css`:

```css
@source "../../deps/phoenix_select/lib";
```

## Usage

### Form mode (default)

Render inside your own `<.form>`; the selection participates in the form
like any native input, driving `phx-change`/`phx-submit`:

```heex
<.form for={@form} phx-change="validate" phx-submit="save">
  <PhoenixSelect.select
    id="role"
    field={@form[:role]}
    options={[{"Admin", "admin"}, {"Editor", "editor"}]}
    label="Role"
  />

  <PhoenixSelect.select
    id="tags"
    field={@form[:tags]}
    options={["elixir", "phoenix", "liveview"]}
    multiple
    label="Tags"
  />
</.form>
```

Multi selects submit array params (`user[tags][]`). An empty selection
submits `[""]` so that "clear all" reaches your handler —
`Ecto.Changeset.cast/4` filters the empty string out of array fields
automatically (prune it yourself for map-backed forms).

### URL params mode

Pass `param`, `uri`, and `params` instead of `field`/`name` and the
selection is written to the URL query string via `push_patch` — table/filter
state that survives refreshes and can be shared as a link. Your LiveView
only tracks `uri` and `params` in `handle_params/3`:

```heex
<PhoenixSelect.select
  id="genre-filter"
  param="filter[genre]"
  uri={@uri}
  params={@params}
  options={@genres}
  multiple
  reset_params={["page", "after"]}
/>
```

`reset_params` deletes params that no longer apply when the selection
changes — pagination, typically.

### Server-side search

By default options are filtered client-side: zero keystroke latency, best
for lists that fit comfortably in the DOM. For large or remote datasets,
pass `search_event` and handle it in the parent LiveView — the hook pushes
the event (debounced) as the user types instead of filtering locally:

```heex
<PhoenixSelect.select
  id="country"
  field={@form[:country]}
  options={@country_options}
  search_event="search_country"
/>
```

```elixir
def handle_event("search_country", %{"id" => _id, "query" => query}, socket) do
  {:noreply, assign(socket, :country_options, Countries.search(query))}
end
```

### Keyboard and accessibility

The rendered markup follows the WAI-ARIA combobox pattern:
`role="combobox"` input with `aria-expanded`, `aria-controls`,
`aria-autocomplete="list"`, and `aria-activedescendant` tracking the
highlighted option; `role="listbox"`/`role="option"` with `aria-selected`;
labelled remove/clear buttons.

| Key | Behavior |
| --- | --- |
| `ArrowDown` / `ArrowUp` | Open the dropdown / move the highlight |
| `Enter` | Select the highlighted option |
| `Escape` | Close the dropdown |
| `Backspace` (empty input, multiple) | Remove the last selected value |
| `Tab` | Close and move on |

## Reference

### `PhoenixSelect.select/1` attributes

| Attribute | Type | Default | Notes |
| --- | --- | --- | --- |
| `id` | `:string` | required | Unique DOM id, also used by the hook |
| `field` | `FormField` | `nil` | Form field, e.g. `@form[:role]` (form mode) |
| `name` | `:string` | `nil` | Input name when not using `field` (form mode) |
| `value` | `:any` | `nil` | Current value override; defaults to the field's value |
| `param` | `:string` | `nil` | Query param to write, e.g. `"filter[genre]"` (URL mode) |
| `uri` | `:string` | `nil` | Current URI from `handle_params/3` (URL mode) |
| `params` | `:map` | `%{}` | Current params from `handle_params/3` (URL mode) |
| `reset_params` | `:list` | `[]` | Params deleted whenever the selection changes (URL mode) |
| `options` | `:list` | required | `[{label, value}]` tuples or plain values |
| `multiple` | `:boolean` | `false` | Allow selecting more than one option |
| `label` | `:string` | `nil` | Label rendered above the input |
| `placeholder` | `:string` | `nil` | Placeholder for the search input |
| `disabled` | `:boolean` | `false` | Disables the input |
| `search_event` | `:string` | `nil` | Push this event to the parent instead of filtering client-side |
| `debounce` | `:integer` | `300` | Debounce in ms for `search_event` pushes |
| `class` | `:string` | `nil` | Extra classes for the outer container |

Exactly one integration must be chosen: `field`/`name` (form mode) or
`param` + `uri` (URL mode) — anything else raises at render.

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
