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

  @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)

  prop(on_focus, :event)
  prop(on_blur, :event)

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

  defp is_hidden(option, filter) do
    import String, only: [contains?: 2, downcase: 1]

    is_binary(filter) && String.length(filter) > 0 &&
      !contains?(downcase(option[:key]), downcase(filter))
  end

  def render(assigns) do
    ~F"""
    <Dropdown id={dropdown_id(assigns)} {=@is_open} {=@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}
            {=@on_focus}
            {=@on_blur}
            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>
          <Dropdown.Option
            id={gen_rand_id()}
            :for={option <- @options}
            {=@size}
            class={(is_hidden(option, @filter) && "hidden") || "p-0"}
            hook="Combobox"
          >
            <div class="flex w-full h-full pl-2">
              <Checkbox
                checked_value={option[:value]}
                :if={@is_multiple}
                disabled={option[:disabled]}
                {=@size}
                hidden_input={false}
                is_multiple
                checkbox_label_class="relative flex items-center gap-2 text-moon-16 w-full h-full [&>span]:relative [&>input]:absolute"
              >
                <div class="w-full text-start pt-2"><#slot {@option, option: option}>{option[:key]}</#slot></div>
              </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>
            </div>
          </Dropdown.Option>
        </Dropdown.Options>
      </#slot>
    </Dropdown>
    """
  end
end