lib/moon/design/form/combobox.ex

defmodule Moon.Design.Form.Combobox do
  @moduledoc "Fully customized select component for the forms with input for filtering"

  use Moon.StatelessComponent

  alias Surface.Components.Form
  alias Moon.Design.Dropdown
  alias Moon.Design.Form.Checkbox
  alias Moon.Design.Form.Radio
  alias Moon.Icon
  alias Moon.Design.Dropdown.Badge

  import Moon.Helpers.Form
  import Enum, only: [map: 2, filter: 2, member?: 2]

  @doc "Name of the field, usually should be taken from context"
  prop(field, :atom, from_context: {Form.Field, :field})
  @doc "Form info, usually should be taken from context"
  prop(form, :form, from_context: {Form, :form})

  @doc "... format: [%{key: shown_label, value: option_value, disabled: bool}], diisabled is optional"
  prop(options, :list, required: true)

  @doc "Selected option(s) value - do not use it inside the form, just for away-from-form components"
  prop(value, :any)
  @doc "HTML disabled attribute for the input & some additional classes"
  prop(disabled, :boolean)

  @doc "Common moon size property"
  prop(size, :string, values!: ~w(sm md lg), default: "md")
  @doc "Additional classes for the <select> tag"
  prop(class, :css_class, from_context: :class)
  @doc "Some prompt to be shown on empty value"
  prop(prompt, :string)

  @doc "Id to be given to the select tag"
  prop(id, :string)
  @doc "Data-testid attribute value"
  prop(testid, :string)

  @doc "Some additional styling will be set to indicate field is invalid"
  prop(error, :boolean, from_context: :error)

  @doc "If field does support multiselect, `multiple` attribute for select tag in HTML terms"
  prop(is_multiple, :boolean)

  @doc "Should dropdown be open"
  prop(is_open, :boolean)

  @doc "Keyword | Map of additional attributes for the input"
  prop(attrs, :any, default: %{})

  @doc "Option for custom stylings - use it to show icons or anything else"
  slot(default)

  @doc "Trigger element for the dropdown, default is Dropdown.Select"
  slot(trigger)

  @doc "On key up event for the input - use it for filter options outside the form"
  prop(on_keyup, :event)

  @doc "Filtering value for the options"
  prop(filter, :string)

  @doc "Slot used for rendering single option. option[:key] will be used if not given"
  slot(option)

  defp hidden_selected_values(%{is_multiple: true, form: form, field: field, options: options}) do
    option_values = options |> map(& &1[:value])
    (form[field].value || []) |> filter(&(!member?(option_values, &1)))
  end

  defp hidden_selected_values(%{form: form, field: field, options: options}) do
    (form[field].value in map(options, & &1[:value]) && nil) || form[field].value
  end

  def render(assigns) do
    ~F"""
    <Dropdown id={dropdown_id(assigns)} {=@is_open} hook="Combobox" {=@testid} {=@class}>
      <:trigger :let={is_open: is_open, on_trigger: on_trigger}>
        <#slot {@trigger, is_open: is_open, on_trigger: on_trigger} context_put={on_keyup: @on_keyup}>
          <Dropdown.Input
            placeholder={@prompt}
            {=@attrs}
            {=@size}
            {=is_open}
            {=@error}
            {=@disabled}
            {=@on_keyup}
            value={(select_value(assigns) && select_value(assigns)[:key]) || @filter}
            class={
              input_classes(assigns) ++
                input_size_classes(assigns) ++
                ["ps-11": select_badge(assigns) && @size == "sm"],
              ["ps-[3.25rem]": select_badge(assigns) && @size == "md"],
              ["ps-14": select_badge(assigns) && @size == "lg"]
            }
          >
            <Icon
              name="controls_chevron_down"
              class={
                "transition-200 transition-transform cursor-pointer text-trunks text-moon-16",
                "absolute ltr:right-4 rtl:left-4 top-1/2 -translate-y-1/2 z-[3]",
                "rotate-180": is_open
              }
              click={on_trigger}
            />
            <Badge
              :if={select_badge(assigns)}
              {=@size}
              count={select_badge(assigns)}
              class={
                "absolute top-1/2 -translate-y-1/2 z-[3]",
                "rtl:right-2 ltr:left-2": @size in ~w(sm md),
                "rtl:right-3 ltr:left-3": @size == "lg"
              }
            />
          </Dropdown.Input>
        </#slot>
      </:trigger>
      <#slot {@default}>
        <Dropdown.Options>
          {#if @is_multiple}
            <Surface.Components.Form.HiddenInput
              :for={value <- hidden_selected_values(assigns)}
              name={"#{@form[@field].name}[]"}
              {=value}
              class="hidden"
            />
          {#else}
            <Surface.Components.Form.HiddenInput
              :if={hidden_selected_values(assigns)}
              name={"#{@form[@field].name}"}
              value={hidden_selected_values(assigns)}
              class="hidden"
            />
          {/if}
          <Dropdown.Option :for={option <- @options} {=@size}>
            <Checkbox
              checked_value={option[:value]}
              :if={@is_multiple}
              disabled={option[:disabled]}
              {=@size}
              hidden_input={false}
              is_multiple
            >
              <#slot {@option, option: option}>{option[:key]}</#slot>
            </Checkbox>
            <Radio.Button value={option[:value]} :if={!@is_multiple} disabled={option[:disabled]} {=@size}>
              <#slot {@option, option: option}>
                <Radio.Indicator class="hidden" />
                {option[:key]}
              </#slot>
            </Radio.Button>
          </Dropdown.Option>
        </Dropdown.Options>
      </#slot>
    </Dropdown>
    """
  end
end