An example AT Protocol application, written in Elixir using atex and Drinkup.
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