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