blonk is a radar for your web, where you follow vibes for cool blips on the radar
at main 676 lines 22 kB view raw
1defmodule ElixirBlonkWeb.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 modals, tables, and 7 forms. 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 default components use Tailwind CSS, a utility-first CSS framework. 12 See the [Tailwind CSS documentation](https://tailwindcss.com) to learn 13 how to customize them or feel free to swap in another framework altogether. 14 15 Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. 16 """ 17 use Phoenix.Component 18 use Gettext, backend: ElixirBlonkWeb.Gettext 19 20 alias Phoenix.LiveView.JS 21 22 @doc """ 23 Renders a modal. 24 25 ## Examples 26 27 <.modal id="confirm-modal"> 28 This is a modal. 29 </.modal> 30 31 JS commands may be passed to the `:on_cancel` to configure 32 the closing/cancel event, for example: 33 34 <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> 35 This is another modal. 36 </.modal> 37 38 """ 39 attr :id, :string, required: true 40 attr :show, :boolean, default: false 41 attr :on_cancel, JS, default: %JS{} 42 slot :inner_block, required: true 43 44 def modal(assigns) do 45 ~H""" 46 <div 47 id={@id} 48 phx-mounted={@show && show_modal(@id)} 49 phx-remove={hide_modal(@id)} 50 data-cancel={JS.exec(@on_cancel, "phx-remove")} 51 class="relative z-50 hidden" 52 > 53 <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" /> 54 <div 55 class="fixed inset-0 overflow-y-auto" 56 aria-labelledby={"#{@id}-title"} 57 aria-describedby={"#{@id}-description"} 58 role="dialog" 59 aria-modal="true" 60 tabindex="0" 61 > 62 <div class="flex min-h-full items-center justify-center"> 63 <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8"> 64 <.focus_wrap 65 id={"#{@id}-container"} 66 phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} 67 phx-key="escape" 68 phx-click-away={JS.exec("data-cancel", to: "##{@id}")} 69 class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition" 70 > 71 <div class="absolute top-6 right-5"> 72 <button 73 phx-click={JS.exec("data-cancel", to: "##{@id}")} 74 type="button" 75 class="-m-3 flex-none p-3 opacity-20 hover:opacity-40" 76 aria-label={gettext("close")} 77 > 78 <.icon name="hero-x-mark-solid" class="h-5 w-5" /> 79 </button> 80 </div> 81 <div id={"#{@id}-content"}> 82 {render_slot(@inner_block)} 83 </div> 84 </.focus_wrap> 85 </div> 86 </div> 87 </div> 88 </div> 89 """ 90 end 91 92 @doc """ 93 Renders flash notices. 94 95 ## Examples 96 97 <.flash kind={:info} flash={@flash} /> 98 <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> 99 """ 100 attr :id, :string, doc: "the optional id of flash container" 101 attr :flash, :map, default: %{}, doc: "the map of flash messages to display" 102 attr :title, :string, default: nil 103 attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" 104 attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" 105 106 slot :inner_block, doc: "the optional inner block that renders the flash message" 107 108 def flash(assigns) do 109 assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) 110 111 ~H""" 112 <div 113 :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} 114 id={@id} 115 phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} 116 role="alert" 117 class={[ 118 "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", 119 @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", 120 @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900" 121 ]} 122 {@rest} 123 > 124 <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6"> 125 <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> 126 <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> 127 {@title} 128 </p> 129 <p class="mt-2 text-sm leading-5">{msg}</p> 130 <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}> 131 <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" /> 132 </button> 133 </div> 134 """ 135 end 136 137 @doc """ 138 Shows the flash group with standard titles and content. 139 140 ## Examples 141 142 <.flash_group flash={@flash} /> 143 """ 144 attr :flash, :map, required: true, doc: "the map of flash messages" 145 attr :id, :string, default: "flash-group", doc: "the optional id of flash container" 146 147 def flash_group(assigns) do 148 ~H""" 149 <div id={@id}> 150 <.flash kind={:info} title={gettext("Success!")} flash={@flash} /> 151 <.flash kind={:error} title={gettext("Error!")} flash={@flash} /> 152 <.flash 153 id="client-error" 154 kind={:error} 155 title={gettext("We can't find the internet")} 156 phx-disconnected={show(".phx-client-error #client-error")} 157 phx-connected={hide("#client-error")} 158 hidden 159 > 160 {gettext("Attempting to reconnect")} 161 <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" /> 162 </.flash> 163 164 <.flash 165 id="server-error" 166 kind={:error} 167 title={gettext("Something went wrong!")} 168 phx-disconnected={show(".phx-server-error #server-error")} 169 phx-connected={hide("#server-error")} 170 hidden 171 > 172 {gettext("Hang in there while we get back on track")} 173 <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" /> 174 </.flash> 175 </div> 176 """ 177 end 178 179 @doc """ 180 Renders a simple form. 181 182 ## Examples 183 184 <.simple_form for={@form} phx-change="validate" phx-submit="save"> 185 <.input field={@form[:email]} label="Email"/> 186 <.input field={@form[:username]} label="Username" /> 187 <:actions> 188 <.button>Save</.button> 189 </:actions> 190 </.simple_form> 191 """ 192 attr :for, :any, required: true, doc: "the data structure for the form" 193 attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" 194 195 attr :rest, :global, 196 include: ~w(autocomplete name rel action enctype method novalidate target multipart), 197 doc: "the arbitrary HTML attributes to apply to the form tag" 198 199 slot :inner_block, required: true 200 slot :actions, doc: "the slot for form actions, such as a submit button" 201 202 def simple_form(assigns) do 203 ~H""" 204 <.form :let={f} for={@for} as={@as} {@rest}> 205 <div class="mt-10 space-y-8 bg-white"> 206 {render_slot(@inner_block, f)} 207 <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> 208 {render_slot(action, f)} 209 </div> 210 </div> 211 </.form> 212 """ 213 end 214 215 @doc """ 216 Renders a button. 217 218 ## Examples 219 220 <.button>Send!</.button> 221 <.button phx-click="go" class="ml-2">Send!</.button> 222 """ 223 attr :type, :string, default: nil 224 attr :class, :string, default: nil 225 attr :rest, :global, include: ~w(disabled form name value) 226 227 slot :inner_block, required: true 228 229 def button(assigns) do 230 ~H""" 231 <button 232 type={@type} 233 class={[ 234 "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3", 235 "text-sm font-semibold leading-6 text-white active:text-white/80", 236 @class 237 ]} 238 {@rest} 239 > 240 {render_slot(@inner_block)} 241 </button> 242 """ 243 end 244 245 @doc """ 246 Renders an input with label and error messages. 247 248 A `Phoenix.HTML.FormField` may be passed as argument, 249 which is used to retrieve the input name, id, and values. 250 Otherwise all attributes may be passed explicitly. 251 252 ## Types 253 254 This function accepts all HTML input types, considering that: 255 256 * You may also set `type="select"` to render a `<select>` tag 257 258 * `type="checkbox"` is used exclusively to render boolean values 259 260 * For live file uploads, see `Phoenix.Component.live_file_input/1` 261 262 See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input 263 for more information. Unsupported types, such as hidden and radio, 264 are best written directly in your templates. 265 266 ## Examples 267 268 <.input field={@form[:email]} type="email" /> 269 <.input name="my-input" errors={["oh no!"]} /> 270 """ 271 attr :id, :any, default: nil 272 attr :name, :any 273 attr :label, :string, default: nil 274 attr :value, :any 275 276 attr :type, :string, 277 default: "text", 278 values: ~w(checkbox color date datetime-local email file month number password 279 range search select tel text textarea time url week) 280 281 attr :field, Phoenix.HTML.FormField, 282 doc: "a form field struct retrieved from the form, for example: @form[:email]" 283 284 attr :errors, :list, default: [] 285 attr :checked, :boolean, doc: "the checked flag for checkbox inputs" 286 attr :prompt, :string, default: nil, doc: "the prompt for select inputs" 287 attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" 288 attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" 289 290 attr :rest, :global, 291 include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength 292 multiple pattern placeholder readonly required rows size step) 293 294 def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do 295 errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] 296 297 assigns 298 |> assign(field: nil, id: assigns.id || field.id) 299 |> assign(:errors, Enum.map(errors, &translate_error(&1))) 300 |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) 301 |> assign_new(:value, fn -> field.value end) 302 |> input() 303 end 304 305 def input(%{type: "checkbox"} = assigns) do 306 assigns = 307 assign_new(assigns, :checked, fn -> 308 Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) 309 end) 310 311 ~H""" 312 <div> 313 <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600"> 314 <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> 315 <input 316 type="checkbox" 317 id={@id} 318 name={@name} 319 value="true" 320 checked={@checked} 321 class="rounded border-zinc-300 text-zinc-900 focus:ring-0" 322 {@rest} 323 /> 324 {@label} 325 </label> 326 <.error :for={msg <- @errors}>{msg}</.error> 327 </div> 328 """ 329 end 330 331 def input(%{type: "select"} = assigns) do 332 ~H""" 333 <div> 334 <.label for={@id}>{@label}</.label> 335 <select 336 id={@id} 337 name={@name} 338 class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 339 multiple={@multiple} 340 {@rest} 341 > 342 <option :if={@prompt} value="">{@prompt}</option> 343 {Phoenix.HTML.Form.options_for_select(@options, @value)} 344 </select> 345 <.error :for={msg <- @errors}>{msg}</.error> 346 </div> 347 """ 348 end 349 350 def input(%{type: "textarea"} = assigns) do 351 ~H""" 352 <div> 353 <.label for={@id}>{@label}</.label> 354 <textarea 355 id={@id} 356 name={@name} 357 class={[ 358 "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]", 359 @errors == [] && "border-zinc-300 focus:border-zinc-400", 360 @errors != [] && "border-rose-400 focus:border-rose-400" 361 ]} 362 {@rest} 363 >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea> 364 <.error :for={msg <- @errors}>{msg}</.error> 365 </div> 366 """ 367 end 368 369 # All other inputs text, datetime-local, url, password, etc. are handled here... 370 def input(assigns) do 371 ~H""" 372 <div> 373 <.label for={@id}>{@label}</.label> 374 <input 375 type={@type} 376 name={@name} 377 id={@id} 378 value={Phoenix.HTML.Form.normalize_value(@type, @value)} 379 class={[ 380 "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6", 381 @errors == [] && "border-zinc-300 focus:border-zinc-400", 382 @errors != [] && "border-rose-400 focus:border-rose-400" 383 ]} 384 {@rest} 385 /> 386 <.error :for={msg <- @errors}>{msg}</.error> 387 </div> 388 """ 389 end 390 391 @doc """ 392 Renders a label. 393 """ 394 attr :for, :string, default: nil 395 slot :inner_block, required: true 396 397 def label(assigns) do 398 ~H""" 399 <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800"> 400 {render_slot(@inner_block)} 401 </label> 402 """ 403 end 404 405 @doc """ 406 Generates a generic error message. 407 """ 408 slot :inner_block, required: true 409 410 def error(assigns) do 411 ~H""" 412 <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600"> 413 <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 414 {render_slot(@inner_block)} 415 </p> 416 """ 417 end 418 419 @doc """ 420 Renders a header with title. 421 """ 422 attr :class, :string, default: nil 423 424 slot :inner_block, required: true 425 slot :subtitle 426 slot :actions 427 428 def header(assigns) do 429 ~H""" 430 <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}> 431 <div> 432 <h1 class="text-lg font-semibold leading-8 text-zinc-800"> 433 {render_slot(@inner_block)} 434 </h1> 435 <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> 436 {render_slot(@subtitle)} 437 </p> 438 </div> 439 <div class="flex-none">{render_slot(@actions)}</div> 440 </header> 441 """ 442 end 443 444 @doc ~S""" 445 Renders a table with generic styling. 446 447 ## Examples 448 449 <.table id="users" rows={@users}> 450 <:col :let={user} label="id">{user.id}</:col> 451 <:col :let={user} label="username">{user.username}</:col> 452 </.table> 453 """ 454 attr :id, :string, required: true 455 attr :rows, :list, required: true 456 attr :row_id, :any, default: nil, doc: "the function for generating the row id" 457 attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 458 459 attr :row_item, :any, 460 default: &Function.identity/1, 461 doc: "the function for mapping each row before calling the :col and :action slots" 462 463 slot :col, required: true do 464 attr :label, :string 465 end 466 467 slot :action, doc: "the slot for showing user actions in the last table column" 468 469 def table(assigns) do 470 assigns = 471 with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 472 assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 473 end 474 475 ~H""" 476 <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0"> 477 <table class="w-[40rem] mt-11 sm:w-full"> 478 <thead class="text-sm text-left leading-6 text-zinc-500"> 479 <tr> 480 <th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th> 481 <th :if={@action != []} class="relative p-0 pb-4"> 482 <span class="sr-only">{gettext("Actions")}</span> 483 </th> 484 </tr> 485 </thead> 486 <tbody 487 id={@id} 488 phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} 489 class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700" 490 > 491 <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50"> 492 <td 493 :for={{col, i} <- Enum.with_index(@col)} 494 phx-click={@row_click && @row_click.(row)} 495 class={["relative p-0", @row_click && "hover:cursor-pointer"]} 496 > 497 <div class="block py-4 pr-6"> 498 <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" /> 499 <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}> 500 {render_slot(col, @row_item.(row))} 501 </span> 502 </div> 503 </td> 504 <td :if={@action != []} class="relative w-14 p-0"> 505 <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium"> 506 <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" /> 507 <span 508 :for={action <- @action} 509 class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 510 > 511 {render_slot(action, @row_item.(row))} 512 </span> 513 </div> 514 </td> 515 </tr> 516 </tbody> 517 </table> 518 </div> 519 """ 520 end 521 522 @doc """ 523 Renders a data list. 524 525 ## Examples 526 527 <.list> 528 <:item title="Title">{@post.title}</:item> 529 <:item title="Views">{@post.views}</:item> 530 </.list> 531 """ 532 slot :item, required: true do 533 attr :title, :string, required: true 534 end 535 536 def list(assigns) do 537 ~H""" 538 <div class="mt-14"> 539 <dl class="-my-4 divide-y divide-zinc-100"> 540 <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8"> 541 <dt class="w-1/4 flex-none text-zinc-500">{item.title}</dt> 542 <dd class="text-zinc-700">{render_slot(item)}</dd> 543 </div> 544 </dl> 545 </div> 546 """ 547 end 548 549 @doc """ 550 Renders a back navigation link. 551 552 ## Examples 553 554 <.back navigate={~p"/posts"}>Back to posts</.back> 555 """ 556 attr :navigate, :any, required: true 557 slot :inner_block, required: true 558 559 def back(assigns) do 560 ~H""" 561 <div class="mt-16"> 562 <.link 563 navigate={@navigate} 564 class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 565 > 566 <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> 567 {render_slot(@inner_block)} 568 </.link> 569 </div> 570 """ 571 end 572 573 @doc """ 574 Renders a [Heroicon](https://heroicons.com). 575 576 Heroicons come in three styles – outline, solid, and mini. 577 By default, the outline style is used, but solid and mini may 578 be applied by using the `-solid` and `-mini` suffix. 579 580 You can customize the size and colors of the icons by setting 581 width, height, and background color classes. 582 583 Icons are extracted from the `deps/heroicons` directory and bundled within 584 your compiled app.css by the plugin in your `assets/tailwind.config.js`. 585 586 ## Examples 587 588 <.icon name="hero-x-mark-solid" /> 589 <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> 590 """ 591 attr :name, :string, required: true 592 attr :class, :string, default: nil 593 594 def icon(%{name: "hero-" <> _} = assigns) do 595 ~H""" 596 <span class={[@name, @class]} /> 597 """ 598 end 599 600 ## JS Commands 601 602 def show(js \\ %JS{}, selector) do 603 JS.show(js, 604 to: selector, 605 time: 300, 606 transition: 607 {"transition-all transform ease-out duration-300", 608 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 609 "opacity-100 translate-y-0 sm:scale-100"} 610 ) 611 end 612 613 def hide(js \\ %JS{}, selector) do 614 JS.hide(js, 615 to: selector, 616 time: 200, 617 transition: 618 {"transition-all transform ease-in duration-200", 619 "opacity-100 translate-y-0 sm:scale-100", 620 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 621 ) 622 end 623 624 def show_modal(js \\ %JS{}, id) when is_binary(id) do 625 js 626 |> JS.show(to: "##{id}") 627 |> JS.show( 628 to: "##{id}-bg", 629 time: 300, 630 transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} 631 ) 632 |> show("##{id}-container") 633 |> JS.add_class("overflow-hidden", to: "body") 634 |> JS.focus_first(to: "##{id}-content") 635 end 636 637 def hide_modal(js \\ %JS{}, id) do 638 js 639 |> JS.hide( 640 to: "##{id}-bg", 641 transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} 642 ) 643 |> hide("##{id}-container") 644 |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 645 |> JS.remove_class("overflow-hidden", to: "body") 646 |> JS.pop_focus() 647 end 648 649 @doc """ 650 Translates an error message using gettext. 651 """ 652 def translate_error({msg, opts}) do 653 # When using gettext, we typically pass the strings we want 654 # to translate as a static argument: 655 # 656 # # Translate the number of files with plural rules 657 # dngettext("errors", "1 file", "%{count} files", count) 658 # 659 # However the error messages in our forms and APIs are generated 660 # dynamically, so we need to translate them by calling Gettext 661 # with our gettext backend as first argument. Translations are 662 # available in the errors.po file (as we use the "errors" domain). 663 if count = opts[:count] do 664 Gettext.dngettext(ElixirBlonkWeb.Gettext, "errors", msg, msg, count, opts) 665 else 666 Gettext.dgettext(ElixirBlonkWeb.Gettext, "errors", msg, opts) 667 end 668 end 669 670 @doc """ 671 Translates the errors for a field from a keyword list of errors. 672 """ 673 def translate_errors(errors, field) when is_list(errors) do 674 for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 675 end 676end