An example AT Protocol application, written in Elixir using atex and Drinkup.
at main 462 lines 14 kB view raw
1defmodule StatusphereWeb.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 * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - 23 the component system used by Phoenix. Some components, such as `<.link>` 24 and `<.form>`, are defined there. 25 26 """ 27 use Phoenix.Component 28 29 alias Phoenix.LiveView.JS 30 31 @doc """ 32 Renders flash notices. 33 34 ## Examples 35 36 <.flash kind={:info} flash={@flash} /> 37 <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> 38 """ 39 attr :id, :string, doc: "the optional id of flash container" 40 attr :flash, :map, default: %{}, doc: "the map of flash messages to display" 41 attr :title, :string, default: nil 42 attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" 43 attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" 44 45 slot :inner_block, doc: "the optional inner block that renders the flash message" 46 47 def flash(assigns) do 48 assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) 49 50 ~H""" 51 <div 52 :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} 53 id={@id} 54 phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} 55 role="alert" 56 class="toast toast-top toast-end z-50" 57 {@rest} 58 > 59 <div class={[ 60 "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", 61 @kind == :info && "alert-info", 62 @kind == :error && "alert-error" 63 ]}> 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="close"> 70 x 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 {render_slot(@inner_block)} 298 </p> 299 """ 300 end 301 302 @doc """ 303 Renders a header with title. 304 """ 305 slot :inner_block, required: true 306 slot :subtitle 307 slot :actions 308 309 def header(assigns) do 310 ~H""" 311 <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}> 312 <div> 313 <h1 class="text-lg font-semibold leading-8"> 314 {render_slot(@inner_block)} 315 </h1> 316 <p :if={@subtitle != []} class="text-sm text-base-content/70"> 317 {render_slot(@subtitle)} 318 </p> 319 </div> 320 <div class="flex-none">{render_slot(@actions)}</div> 321 </header> 322 """ 323 end 324 325 @doc """ 326 Renders a table with generic styling. 327 328 ## Examples 329 330 <.table id="users" rows={@users}> 331 <:col :let={user} label="id">{user.id}</:col> 332 <:col :let={user} label="username">{user.username}</:col> 333 </.table> 334 """ 335 attr :id, :string, required: true 336 attr :rows, :list, required: true 337 attr :row_id, :any, default: nil, doc: "the function for generating the row id" 338 attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 339 340 attr :row_item, :any, 341 default: &Function.identity/1, 342 doc: "the function for mapping each row before calling the :col and :action slots" 343 344 slot :col, required: true do 345 attr :label, :string 346 end 347 348 slot :action, doc: "the slot for showing user actions in the last table column" 349 350 def table(assigns) do 351 assigns = 352 with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 353 assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 354 end 355 356 ~H""" 357 <table class="table table-zebra"> 358 <thead> 359 <tr> 360 <th :for={col <- @col}>{col[:label]}</th> 361 <th :if={@action != []}> 362 <span class="sr-only">Actions</span> 363 </th> 364 </tr> 365 </thead> 366 <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}> 367 <tr :for={row <- @rows} id={@row_id && @row_id.(row)}> 368 <td 369 :for={col <- @col} 370 phx-click={@row_click && @row_click.(row)} 371 class={@row_click && "hover:cursor-pointer"} 372 > 373 {render_slot(col, @row_item.(row))} 374 </td> 375 <td :if={@action != []} class="w-0 font-semibold"> 376 <div class="flex gap-4"> 377 <%= for action <- @action do %> 378 {render_slot(action, @row_item.(row))} 379 <% end %> 380 </div> 381 </td> 382 </tr> 383 </tbody> 384 </table> 385 """ 386 end 387 388 @doc """ 389 Renders a data list. 390 391 ## Examples 392 393 <.list> 394 <:item title="Title">{@post.title}</:item> 395 <:item title="Views">{@post.views}</:item> 396 </.list> 397 """ 398 slot :item, required: true do 399 attr :title, :string, required: true 400 end 401 402 def list(assigns) do 403 ~H""" 404 <ul class="list"> 405 <li :for={item <- @item} class="list-row"> 406 <div class="list-col-grow"> 407 <div class="font-bold">{item.title}</div> 408 <div>{render_slot(item)}</div> 409 </div> 410 </li> 411 </ul> 412 """ 413 end 414 415 ## JS Commands 416 417 def show(js \\ %JS{}, selector) do 418 JS.show(js, 419 to: selector, 420 time: 300, 421 transition: 422 {"transition-all ease-out duration-300", 423 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 424 "opacity-100 translate-y-0 sm:scale-100"} 425 ) 426 end 427 428 def hide(js \\ %JS{}, selector) do 429 JS.hide(js, 430 to: selector, 431 time: 200, 432 transition: 433 {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", 434 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 435 ) 436 end 437 438 @doc """ 439 Translates an error message using gettext. 440 """ 441 def translate_error({msg, opts}) do 442 # You can make use of gettext to translate error messages by 443 # uncommenting and adjusting the following code: 444 445 # if count = opts[:count] do 446 # Gettext.dngettext(StatusphereWeb.Gettext, "errors", msg, msg, count, opts) 447 # else 448 # Gettext.dgettext(StatusphereWeb.Gettext, "errors", msg, opts) 449 # end 450 451 Enum.reduce(opts, msg, fn {key, value}, acc -> 452 String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 453 end) 454 end 455 456 @doc """ 457 Translates the errors for a field from a keyword list of errors. 458 """ 459 def translate_errors(errors, field) when is_list(errors) do 460 for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 461 end 462end