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