defmodule LiveViewContinuity.Select do @moduledoc "An unstyled custom single select with a native form bridge. See `SELECT.md`." use Phoenix.Component alias Phoenix.LiveView.JS attr(:id, :string, required: true) attr(:name, :string, required: true) attr(:value, :string, required: true) attr(:on_change, :string, required: true) attr(:label, :string, required: true) attr(:placeholder, :string, default: nil) attr(:required, :boolean, default: false) attr(:disabled, :boolean, default: false) attr(:read_only, :boolean, default: false) attr(:invalid, :boolean, default: false) attr(:form, :string, default: nil) attr(:class, :any, default: nil) attr(:trigger_class, :any, default: nil) attr(:popup_class, :any, default: nil) attr(:native_class, :any, default: nil) attr(:label_class, :any, default: nil) attr(:description_class, :any, default: nil) attr(:error_class, :any, default: nil) attr(:rest, :global) slot :option, required: true do attr(:value, :string, required: true) attr(:disabled, :boolean) attr(:class, :any) end slot(:description) slot(:error) def select(assigns) do assigns = if assigns.value == "", do: assign(assigns, :value, nil), else: assigns validate!(assigns) assigns = assigns |> assign(:describedby, describedby(assigns)) |> assign(:selected_option, Enum.find(assigns.option, &(&1.value == assigns.value))) ~H"""
"-label"} class={@label_class}>{@label}
"-popup"} class={@popup_class} role="listbox" popover="manual" aria-labelledby={@id <> "-label"} tabindex="-1" data-lvc-select-popup >
{render_slot(option)}
"-description"} class={@description_class}> {render_slot(@description)}
"-error"} class={@error_class} hidden={!@invalid}> {render_slot(@error)}
""" end defp validate!(a) do if not is_binary(a.id) or a.id == "" or String.match?(a.id, ~r/[\x00\x09-\x0D\x20]/), do: raise(ArgumentError, "select id must be non-empty and contain no ASCII whitespace or NUL") for {value, name} <- [ {a.name, "name"}, {a.on_change, "on_change"}, {a.label, "label"} ], do: nonblank!(value, name) if a.value != nil and not is_binary(a.value), do: raise(ArgumentError, "select value must be a string or nil") if length(a.description) > 1 or length(a.error) > 1, do: raise(ArgumentError, "select accepts at most one description and one error") values = Enum.map(a.option, fn o -> nonblank!(o.value, "option value") o.value end) if length(values) != length(Enum.uniq(values)), do: raise(ArgumentError, "select option values must be unique") if a.value != nil and a.value not in values, do: raise(ArgumentError, "select value must match an option") end defp nonblank!(v, name) when is_binary(v), do: if(String.trim(v) == "" or String.contains?(v, <<0>>), do: raise(ArgumentError, "select #{name} must be non-blank") ) defp nonblank!(_, name), do: raise(ArgumentError, "select #{name} must be a string") defp option_id(id, value), do: id <> "-option-" <> Base.url_encode64(value, padding: false) defp describedby(a), do: [ a.description != [] && a.id <> "-description", a.invalid && a.error != [] && a.id <> "-error" ] |> Enum.reject(&(&1 == false)) |> Enum.join(" ") |> then(&if(&1 == "", do: nil, else: &1)) end