Current section

Files

Jump to
pui lib pui select.ex
Raw

lib/pui/select.ex

defmodule PUI.Select do
@moduledoc """
A customizable select dropdown with search, grouping, and form-aware errors.
## Basic Usage
<.select id="food" name="food">
<.select_item value="apple">Apple</.select_item>
<.select_item value="banana">Banana</.select_item>
<.select_item value="orange">Orange</.select_item>
</.select>
## With Options List
Pass options as a list for automatic rendering:
<.select id="food" name="food" options={["Apple", "Banana", "Orange"]} />
## With Value/Label Pairs
<.select id="food" name="food" options={[
{"apple", "Apple"},
{"banana", "Banana"}
]} />
## With Groups
<.select id="food" name="food" options={[
{"Fruits", ["Apple", "Banana"]},
{"Vegetables", [{"carrot", "Carrot"}]}
]} />
## Searchable Select
<.select id="food" name="food" searchable={true} options={["Option 1", "Option 2"]} />
## With Label
<.select id="food" name="food" label="Select Food">
<:option value="apple">Apple</:option>
</.select>
## With Phoenix Form
<.form for={@form}>
<.select field={@form[:category]} options={@categories} />
</.form>
When `field` is provided, the component derives `id`, `name`, `value`, and
validation errors from the form field and renders those errors below the
trigger once the field has been used.
## Manual Errors
<.select
id="category"
name="category"
label="Category"
errors={["Please choose a category."]}
options={["Design", "Engineering", "Marketing"]}
/>
## With Icons
<.select id="food" name="food">
<.select_item value="apple">
<.icon name="hero-apple" class="size-4" /> Apple
</.select_item>
</.select>
## With Footer
Add custom content at the bottom of the dropdown, such as action buttons:
<.select id="items" name="items" searchable={true}>
<.select_item value="item-1">Item One</.select_item>
<.select_item value="item-2">Item Two</.select_item>
<:footer>
<div class="border-t border-border p-2">
<button type="button" phx-click="add-new" class="text-sm text-primary">
+ Add New Item
</button>
</div>
</:footer>
</.select>
## Attributes (select/1)
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `id` | `string` | `nil` | Unique identifier |
| `name` | `string` | `nil` | Form field name |
| `value` | `string` | `nil` | Selected value |
| `placeholder` | `string` | `"Select an item"` | Placeholder text |
| `options` | `list` | `[]` | List of options (strings, tuples, or groups) |
| `searchable` | `boolean` | `false` | Enable search/filter functionality |
| `class` | `string` | `"w-fit"` | Additional CSS classes |
| `label` | `string` | `nil` | Label text |
| `field` | `FormField` | `nil` | Phoenix form field struct |
| `errors` | `list` | `[]` | Error messages shown below the select |
## Slots
| Slot | Description |
|------|-------------|
| `inner_block` | Custom select items using `<.select_item>` |
| `header` | Content to display at the top of dropdown |
| `footer` | Content to display at the bottom of dropdown |
"""
use Phoenix.Component
import PUI.Input, only: [label: 1]
import PUI.Components, only: [field_error: 1]
@popup_min_width 208
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :string, default: nil
attr :placeholder, :string, default: "Select an item"
attr :options, :list, default: []
attr :searchable, :boolean, default: false
attr :class, :string,
default: "w-full",
doc: "Use w-fit for a width that fits the selected item"
attr :label, :string, default: nil
attr :variant, :string, default: "default", values: ["default", "unstyled"]
attr :field, Phoenix.HTML.FormField,
default: nil,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list,
default: [],
doc: "a list of error strings to display below the select"
attr :show_errors, :boolean, default: true
slot :inner_block
slot :header
slot :footer
@doc """
Renders the select trigger and option list.
Use `options` for the common tuple/string-based API, or provide custom
`select_item/1` entries through the default slot. Both manual `errors` and
field-derived validation errors are rendered below the component.
## Examples
<.select
id="food"
name="food"
label="Favorite Food"
options={["Pizza", "Pasta", "Sushi"]}
/>
<.select
field={@form[:category]}
label="Category"
searchable={true}
options={["Design", "Engineering", "Marketing"]}
/>
<.select
id="category"
name="category"
label="Category"
errors={["Please choose a category."]}
options={["Design", "Engineering", "Marketing"]}
/>
"""
def select(%{label: label} = assigns) when label not in ["", nil] do
assigns =
assigns
|> map_field()
assigns = assign(assigns, :label_for, label_input_id(assigns))
~H"""
<div class="flex flex-col w-full gap-3 pb-3">
<.label for={@label_for}>{@label}</.label>
<div>
<.select {assigns |> Map.delete(:label) |> Map.put(:show_errors, false)} />
</div>
</div>
"""
end
def select(%{options: options} = assigns) when options != [] do
assigns =
assigns
|> map_field()
|> assign(options: normalize_options(options))
|> map_placeholder()
~H"""
<.select
id={@id}
name={@name}
class={@class}
value={@value}
placeholder={@placeholder}
searchable={@searchable}
variant={@variant}
errors={@errors}
>
<%= for {opt, index} <- Enum.with_index(@options) do %>
<%= case opt do %>
<% {:group, group_label} -> %>
<div class="px-2 py-1 text-xs font-semibold text-muted-foreground">{group_label}</div>
<% {val, label} -> %>
<.select_item id={"#{@id}-option-#{index}"} value={val}>{label}</.select_item>
<% end %>
<% end %>
</.select>
"""
end
def select(%{variant: variant} = assigns) do
assigns = map_field(assigns)
is_unstyled = variant == "unstyled"
target = select_target_base(assigns)
listbox_id = if target, do: "#{target}-listbox", else: nil
trigger_id = if target, do: "#{target}-trigger", else: nil
input_id = label_input_id(assigns)
assigns =
assigns
|> assign(:is_unstyled, is_unstyled)
|> assign(:listbox_id, listbox_id)
|> assign(:trigger_id, trigger_id)
|> assign(:input_id, input_id)
|> assign(:has_errors, assigns.errors != [])
|> assign(:popup_min_width, @popup_min_width)
~H"""
<div
id={@id}
data-value={@value}
phx-hook="PUI.Select"
class="relative"
>
<input
id={@input_id}
data-pui="select-value"
type="text"
name={@name}
value={@value}
tabindex="-1"
readonly
aria-hidden="true"
style="position:absolute;width:0;height:0;overflow:hidden;opacity:0;pointer-events:none;border:0;padding:0;margin:0"
/>
<button
id={@trigger_id}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls={@listbox_id}
aria-autocomplete={if @searchable, do: "list", else: nil}
aria-invalid={if @has_errors, do: "true"}
class={
if @is_unstyled do
[@class]
else
[
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring [3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@class
]
end
}
>
<span
data-pui="selected-label"
data-slot="select-value"
data-placeholder={@placeholder}
class="block min-w-0 flex-1 text-left"
style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
>
{@placeholder}
</span>
<.select_icon :if={not @is_unstyled} class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
<div
id={@listbox_id}
role="listbox"
tabindex="-1"
aria-labelledby={@trigger_id}
aria-hidden="true"
data-popup-min-width={@popup_min_width}
data-side="bottom"
data-floating-strategy="absolute"
data-reference-hidden="false"
class={
if @is_unstyled do
[@class]
else
[
"aria-hidden:hidden block bg-popover text-popover-foreground",
"not-aria-hidden:animate-in aria-hidden:animate-out aria-hidden:fade-out-0 not-aria-hidden:fade-in-0 aria-hidden:zoom-out-95 not-aria-hidden:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-52 max-h-[min(calc(var(--pui-select-content-available-height)-0.5rem),20rem)] overflow-hidden rounded-md border border-border shadow-md",
"flex flex-col origin-top data-[reference-hidden=true]:invisible data-[reference-hidden=true]:pointer-events-none data-[side=left]:origin-right data-[side=right]:origin-left data-[side=top]:origin-bottom"
]
end
}
>
<%= if @is_unstyled do %>
{render_slot(@header)}
<div data-pui="menu-items">
{render_slot(@inner_block)}
</div>
{render_slot(@footer)}
<% else %>
<div :if={@header != []} class="shrink-0">
{render_slot(@header)}
</div>
<.select_search :if={@searchable} listbox_id={@listbox_id} />
<div data-pui="menu-viewport" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<div data-pui="menu-items">
{render_slot(@inner_block)}
</div>
</div>
<div :if={@footer != []} class="shrink-0">
{render_slot(@footer)}
</div>
<% end %>
</div>
</div>
<.field_error :if={@show_errors} errors={@errors} />
"""
end
attr :listbox_id, :string, default: nil
def select_search(assigns) do
~H"""
<div data-pui="combobox-search" class="shrink-0 flex h-9 items-center gap-2 border-b px-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-search size-4 shrink-0 opacity-50"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
class="placeholder:text-muted-foreground flex w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50 h-9"
placeholder="Search item..."
autocomplete="off"
autocorrect="off"
spellcheck="false"
role="searchbox"
aria-expanded="false"
aria-controls={@listbox_id}
type="text"
value=""
/>
</div>
"""
end
attr :value, :string, required: true
attr :id, :string, default: nil
attr :class, :string, default: ""
attr :variant, :string, default: "default", values: ["default", "unstyled"]
slot :inner_block
@doc """
Renders a custom option inside `select/1`.
## Examples
<.select_item value="edit">
<.icon name="hero-pencil" class="size-4" /> Edit
</.select_item>
"""
def select_item(%{variant: variant} = assigns) do
is_unstyled = variant == "unstyled"
assigns = assign(assigns, :is_unstyled, is_unstyled)
~H"""
<div
id={@id}
role="option"
aria-selected="false"
tabindex="-1"
data-value={@value}
class={
if @is_unstyled do
[@class]
else
[
"aria-hidden:hidden",
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground",
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@class
]
end
}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :rest, :global
def select_icon(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{@rest}
>
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
</svg>
"""
end
defp normalize_options(options) when is_list(options) do
options
|> Enum.flat_map(&normalize_option/1)
end
defp normalize_option({group_name, group_items}) when is_list(group_items) do
header = {:group, to_string(group_name)}
items =
group_items
|> Enum.map(&normalize_single/1)
[header | items]
end
defp normalize_option({v, l}) do
[{to_string(v), to_string(l)}]
end
defp normalize_option(item) do
[normalize_single(item)]
end
defp normalize_single({v, l}) do
{to_string(v), to_string(l)}
end
defp normalize_single(%{value: v, label: l}) do
{to_string(v), to_string(l)}
end
defp normalize_single(%{value: v}) do
s = to_string(v)
{s, s}
end
defp normalize_single(%{label: l}) do
s = to_string(l)
{s, s}
end
defp normalize_single(item) when is_binary(item) or is_atom(item) or is_number(item) do
s = to_string(item)
{s, s}
end
defp normalize_single(item) do
s = to_string(item)
{s, s}
end
defp map_placeholder(assigns) do
selected = assigns[:value]
placeholder =
if selected && selected != "" do
# search flattened options for a pair matching the selected value
case Enum.find(assigns.options, fn
{:group, _} -> false
{v, _label} -> v == to_string(selected)
end) do
nil -> assigns[:placeholder]
{_v, label} -> label
end
else
assigns[:placeholder]
end
assign(assigns, placeholder: placeholder)
end
defp label_input_id(assigns) do
case select_target_base(assigns) do
nil -> nil
target -> "#{target}-input"
end
end
def map_field(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(:id, assigns.id || field.id)
|> assign(:name, assigns.name || field.name)
|> assign(:value, assigns.value || field.value)
|> assign(:field, nil)
|> assign(:errors, Enum.map(errors, &PUI.Components.translate_error/1))
end
def map_field(assigns) do
assigns
end
defp select_target_base(assigns) do
case assigns.id || assigns.name do
value when value in [nil, ""] -> nil
value -> value
end
end
end