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