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