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