daily-tracker

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

core_components.ex (15612B)


      1 defmodule DailyTrackerWeb.CoreComponents do
      2   @moduledoc """
      3   Provides core UI components.
      4 
      5   At first glance, this module may seem daunting, but its goal is to provide
      6   core building blocks for your application, such as tables, forms, and
      7   inputs. The components consist mostly of markup and are well-documented
      8   with doc strings and declarative assigns. You may customize and style
      9   them in any way you want, based on your application growth and needs.
     10 
     11   The foundation for styling is Tailwind CSS, a utility-first CSS framework,
     12   augmented with daisyUI, a Tailwind CSS plugin that provides UI components
     13   and themes. Here are useful references:
     14 
     15     * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
     16       started and see the available components.
     17 
     18     * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
     19       we build on. You will use it for layout, sizing, flexbox, grid, and
     20       spacing.
     21 
     22     * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
     23 
     24     * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
     25       the component system used by Phoenix. Some components, such as `<.link>`
     26       and `<.form>`, are defined there.
     27 
     28   """
     29   use Phoenix.Component
     30   use Gettext, backend: DailyTrackerWeb.Gettext
     31 
     32   alias Phoenix.LiveView.JS
     33 
     34   @doc """
     35   Renders flash notices.
     36 
     37   ## Examples
     38 
     39       <.flash kind={:info} flash={@flash} />
     40       <.flash
     41         id="welcome-back"
     42         kind={:info}
     43         phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
     44         hidden
     45       >
     46         Welcome Back!
     47       </.flash>
     48   """
     49   attr :id, :string, doc: "the optional id of flash container"
     50   attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
     51   attr :title, :string, default: nil
     52   attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
     53   attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
     54 
     55   slot :inner_block, doc: "the optional inner block that renders the flash message"
     56 
     57   def flash(assigns) do
     58     assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
     59 
     60     ~H"""
     61     <div
     62       :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
     63       id={@id}
     64       phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
     65       role="alert"
     66       class="toast toast-top toast-end z-50"
     67       {@rest}
     68     >
     69       <div class={[
     70         "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
     71         @kind == :info && "alert-info",
     72         @kind == :error && "alert-error"
     73       ]}>
     74         <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
     75         <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
     76         <div>
     77           <p :if={@title} class="font-semibold">{@title}</p>
     78           <p>{msg}</p>
     79         </div>
     80         <div class="flex-1" />
     81         <button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
     82           <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
     83         </button>
     84       </div>
     85     </div>
     86     """
     87   end
     88 
     89   @doc """
     90   Renders a button with navigation support.
     91 
     92   ## Examples
     93 
     94       <.button>Send!</.button>
     95       <.button phx-click="go" variant="primary">Send!</.button>
     96       <.button navigate={~p"/"}>Home</.button>
     97   """
     98   attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
     99   attr :class, :any
    100   attr :variant, :string, values: ~w(primary)
    101   slot :inner_block, required: true
    102 
    103   def button(%{rest: rest} = assigns) do
    104     variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
    105 
    106     assigns =
    107       assign_new(assigns, :class, fn ->
    108         ["btn", Map.fetch!(variants, assigns[:variant])]
    109       end)
    110 
    111     if rest[:href] || rest[:navigate] || rest[:patch] do
    112       ~H"""
    113       <.link class={@class} {@rest}>
    114         {render_slot(@inner_block)}
    115       </.link>
    116       """
    117     else
    118       ~H"""
    119       <button class={@class} {@rest}>
    120         {render_slot(@inner_block)}
    121       </button>
    122       """
    123     end
    124   end
    125 
    126   @doc """
    127   Renders an input with label and error messages.
    128 
    129   A `Phoenix.HTML.FormField` may be passed as argument,
    130   which is used to retrieve the input name, id, and values.
    131   Otherwise all attributes may be passed explicitly.
    132 
    133   ## Types
    134 
    135   This function accepts all HTML input types, considering that:
    136 
    137     * You may also set `type="select"` to render a `<select>` tag
    138 
    139     * `type="checkbox"` is used exclusively to render boolean values
    140 
    141     * For live file uploads, see `Phoenix.Component.live_file_input/1`
    142 
    143   See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
    144   for more information. Unsupported types, such as radio, are best
    145   written directly in your templates.
    146 
    147   ## Examples
    148 
    149   ```heex
    150   <.input field={@form[:email]} type="email" />
    151   <.input name="my-input" errors={["oh no!"]} />
    152   ```
    153 
    154   ## Select type
    155 
    156   When using `type="select"`, you must pass the `options` and optionally
    157   a `value` to mark which option should be preselected.
    158 
    159   ```heex
    160   <.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
    161   ```
    162 
    163   For more information on what kind of data can be passed to `options` see
    164   [`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
    165   """
    166   attr :id, :any, default: nil
    167   attr :name, :any
    168   attr :label, :string, default: nil
    169   attr :value, :any
    170 
    171   attr :type, :string,
    172     default: "text",
    173     values: ~w(checkbox color date datetime-local email file month number password
    174                search select tel text textarea time url week hidden)
    175 
    176   attr :field, Phoenix.HTML.FormField,
    177     doc: "a form field struct retrieved from the form, for example: @form[:email]"
    178 
    179   attr :errors, :list, default: []
    180   attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
    181   attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
    182   attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
    183   attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
    184   attr :class, :any, default: nil, doc: "the input class to use over defaults"
    185   attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
    186 
    187   attr :rest, :global,
    188     include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
    189                 multiple pattern placeholder readonly required rows size step)
    190 
    191   def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    192     errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
    193 
    194     assigns
    195     |> assign(field: nil, id: assigns.id || field.id)
    196     |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    197     |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    198     |> assign_new(:value, fn -> field.value end)
    199     |> input()
    200   end
    201 
    202   def input(%{type: "hidden"} = assigns) do
    203     ~H"""
    204     <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    205     """
    206   end
    207 
    208   def input(%{type: "checkbox"} = assigns) do
    209     assigns =
    210       assign_new(assigns, :checked, fn ->
    211         Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
    212       end)
    213 
    214     ~H"""
    215     <div class="fieldset mb-2">
    216       <label for={@id}>
    217         <input
    218           type="hidden"
    219           name={@name}
    220           value="false"
    221           disabled={@rest[:disabled]}
    222           form={@rest[:form]}
    223         />
    224         <span class="label">
    225           <input
    226             type="checkbox"
    227             id={@id}
    228             name={@name}
    229             value="true"
    230             checked={@checked}
    231             class={@class || "checkbox checkbox-sm"}
    232             {@rest}
    233           />{@label}
    234         </span>
    235       </label>
    236       <.error :for={msg <- @errors}>{msg}</.error>
    237     </div>
    238     """
    239   end
    240 
    241   def input(%{type: "select"} = assigns) do
    242     ~H"""
    243     <div class="fieldset mb-2">
    244       <label for={@id}>
    245         <span :if={@label} class="label mb-1">{@label}</span>
    246         <select
    247           id={@id}
    248           name={@name}
    249           class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
    250           multiple={@multiple}
    251           {@rest}
    252         >
    253           <option :if={@prompt} value="">{@prompt}</option>
    254           {Phoenix.HTML.Form.options_for_select(@options, @value)}
    255         </select>
    256       </label>
    257       <.error :for={msg <- @errors}>{msg}</.error>
    258     </div>
    259     """
    260   end
    261 
    262   def input(%{type: "textarea"} = assigns) do
    263     ~H"""
    264     <div class="fieldset mb-2">
    265       <label for={@id}>
    266         <span :if={@label} class="label mb-1">{@label}</span>
    267         <textarea
    268           id={@id}
    269           name={@name}
    270           class={[
    271             @class || "w-full textarea",
    272             @errors != [] && (@error_class || "textarea-error")
    273           ]}
    274           {@rest}
    275         >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
    276       </label>
    277       <.error :for={msg <- @errors}>{msg}</.error>
    278     </div>
    279     """
    280   end
    281 
    282   # All other inputs text, datetime-local, url, password, etc. are handled here...
    283   def input(assigns) do
    284     ~H"""
    285     <div class="fieldset mb-2">
    286       <label for={@id}>
    287         <span :if={@label} class="label mb-1">{@label}</span>
    288         <input
    289           type={@type}
    290           name={@name}
    291           id={@id}
    292           value={Phoenix.HTML.Form.normalize_value(@type, @value)}
    293           class={[
    294             @class || "w-full input",
    295             @errors != [] && (@error_class || "input-error")
    296           ]}
    297           {@rest}
    298         />
    299       </label>
    300       <.error :for={msg <- @errors}>{msg}</.error>
    301     </div>
    302     """
    303   end
    304 
    305   # Helper used by inputs to generate form errors
    306   defp error(assigns) do
    307     ~H"""
    308     <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
    309       <.icon name="hero-exclamation-circle" class="size-5" />
    310       {render_slot(@inner_block)}
    311     </p>
    312     """
    313   end
    314 
    315   @doc """
    316   Renders a header with title.
    317   """
    318   slot :inner_block, required: true
    319   slot :subtitle
    320   slot :actions
    321 
    322   def header(assigns) do
    323     ~H"""
    324     <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
    325       <div>
    326         <h1 class="text-lg font-semibold leading-8">
    327           {render_slot(@inner_block)}
    328         </h1>
    329         <p :if={@subtitle != []} class="text-sm text-base-content/70">
    330           {render_slot(@subtitle)}
    331         </p>
    332       </div>
    333       <div class="flex-none">{render_slot(@actions)}</div>
    334     </header>
    335     """
    336   end
    337 
    338   @doc """
    339   Renders a table with generic styling.
    340 
    341   ## Examples
    342 
    343       <.table id="users" rows={@users}>
    344         <:col :let={user} label="id">{user.id}</:col>
    345         <:col :let={user} label="username">{user.username}</:col>
    346       </.table>
    347   """
    348   attr :id, :string, required: true
    349   attr :rows, :list, required: true
    350   attr :row_id, :any, default: nil, doc: "the function for generating the row id"
    351   attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
    352 
    353   attr :row_item, :any,
    354     default: &Function.identity/1,
    355     doc: "the function for mapping each row before calling the :col and :action slots"
    356 
    357   slot :col, required: true do
    358     attr :label, :string
    359   end
    360 
    361   slot :action, doc: "the slot for showing user actions in the last table column"
    362 
    363   def table(assigns) do
    364     assigns =
    365       with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
    366         assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
    367       end
    368 
    369     ~H"""
    370     <table class="table table-zebra">
    371       <thead>
    372         <tr>
    373           <th :for={col <- @col}>{col[:label]}</th>
    374           <th :if={@action != []}>
    375             <span class="sr-only">{gettext("Actions")}</span>
    376           </th>
    377         </tr>
    378       </thead>
    379       <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
    380         <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
    381           <td
    382             :for={col <- @col}
    383             phx-click={@row_click && @row_click.(row)}
    384             class={@row_click && "hover:cursor-pointer"}
    385           >
    386             {render_slot(col, @row_item.(row))}
    387           </td>
    388           <td :if={@action != []} class="w-0 font-semibold">
    389             <div class="flex gap-4">
    390               <%= for action <- @action do %>
    391                 {render_slot(action, @row_item.(row))}
    392               <% end %>
    393             </div>
    394           </td>
    395         </tr>
    396       </tbody>
    397     </table>
    398     """
    399   end
    400 
    401   @doc """
    402   Renders a data list.
    403 
    404   ## Examples
    405 
    406       <.list>
    407         <:item title="Title">{@post.title}</:item>
    408         <:item title="Views">{@post.views}</:item>
    409       </.list>
    410   """
    411   slot :item, required: true do
    412     attr :title, :string, required: true
    413   end
    414 
    415   def list(assigns) do
    416     ~H"""
    417     <ul class="list">
    418       <li :for={item <- @item} class="list-row">
    419         <div class="list-col-grow">
    420           <div class="font-bold">{item.title}</div>
    421           <div>{render_slot(item)}</div>
    422         </div>
    423       </li>
    424     </ul>
    425     """
    426   end
    427 
    428   @doc """
    429   Renders a [Heroicon](https://heroicons.com).
    430 
    431   Heroicons come in three styles – outline, solid, and mini.
    432   By default, the outline style is used, but solid and mini may
    433   be applied by using the `-solid` and `-mini` suffix.
    434 
    435   You can customize the size and colors of the icons by setting
    436   width, height, and background color classes.
    437 
    438   Icons are extracted from the `deps/heroicons` directory and bundled within
    439   your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
    440 
    441   ## Examples
    442 
    443       <.icon name="hero-x-mark" />
    444       <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
    445   """
    446   attr :name, :string, required: true
    447   attr :class, :any, default: "size-4"
    448 
    449   def icon(%{name: "hero-" <> _} = assigns) do
    450     ~H"""
    451     <span class={[@name, @class]} />
    452     """
    453   end
    454 
    455   ## JS Commands
    456 
    457   def show(js \\ %JS{}, selector) do
    458     JS.show(js,
    459       to: selector,
    460       time: 300,
    461       transition:
    462         {"transition-all ease-out duration-300",
    463          "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
    464          "opacity-100 translate-y-0 sm:scale-100"}
    465     )
    466   end
    467 
    468   def hide(js \\ %JS{}, selector) do
    469     JS.hide(js,
    470       to: selector,
    471       time: 200,
    472       transition:
    473         {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
    474          "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    475     )
    476   end
    477 
    478   @doc """
    479   Translates an error message using gettext.
    480   """
    481   def translate_error({msg, opts}) do
    482     # When using gettext, we typically pass the strings we want
    483     # to translate as a static argument:
    484     #
    485     #     # Translate the number of files with plural rules
    486     #     dngettext("errors", "1 file", "%{count} files", count)
    487     #
    488     # However the error messages in our forms and APIs are generated
    489     # dynamically, so we need to translate them by calling Gettext
    490     # with our gettext backend as first argument. Translations are
    491     # available in the errors.po file (as we use the "errors" domain).
    492     if count = opts[:count] do
    493       Gettext.dngettext(DailyTrackerWeb.Gettext, "errors", msg, msg, count, opts)
    494     else
    495       Gettext.dgettext(DailyTrackerWeb.Gettext, "errors", msg, opts)
    496     end
    497   end
    498 
    499   @doc """
    500   Translates the errors for a field from a keyword list of errors.
    501   """
    502   def translate_errors(errors, field) when is_list(errors) do
    503     for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
    504   end
    505 end