From 21cfefe86f48d84896afbe8e97a30b2d7cd087ab Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Tue, 25 Nov 2025 00:28:03 +0100 Subject: [PATCH 01/12] Support readonly for all fields The `:readonly` field option, being shared among all fields, has been moved to the `@config_schema` of `Backpex.Field`. --- lib/backpex/field.ex | 5 +++++ lib/backpex/fields/belongs_to.ex | 1 + lib/backpex/fields/boolean.ex | 1 + lib/backpex/fields/currency.ex | 1 + lib/backpex/fields/date.ex | 4 ---- lib/backpex/fields/date_time.ex | 4 ---- lib/backpex/fields/email.ex | 4 ---- lib/backpex/fields/has_many.ex | 12 ++++++++++-- lib/backpex/fields/has_many_through.ex | 12 +++++++++--- lib/backpex/fields/inline_crud.ex | 3 ++- lib/backpex/fields/multi_select.ex | 1 + lib/backpex/fields/number.ex | 4 ---- lib/backpex/fields/select.ex | 1 + lib/backpex/fields/text.ex | 4 ---- lib/backpex/fields/textarea.ex | 4 ---- lib/backpex/fields/time.ex | 4 ---- lib/backpex/fields/upload.ex | 14 +++++++++---- lib/backpex/fields/url.ex | 1 + lib/backpex/html/core_components.ex | 22 +++++++++++++++++++-- lib/backpex/html/form.ex | 27 ++++++++++++++++++++++++-- lib/backpex/html/resource.ex | 8 +++++++- 21 files changed, 94 insertions(+), 43 deletions(-) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 7dc82554d..d34c41c72 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -11,6 +11,11 @@ defmodule Backpex.Field do type: :string, required: true ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]}, + default: false + ], class: [ type: {:or, [:string, {:fun, 1}]}, doc: """ diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index 20e9a2493..271b2a462 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -143,6 +143,7 @@ defmodule Backpex.Fields.BelongsTo do field={@form[@owner_key]} options={@options} prompt={@prompt} + readonly={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} help_text={Backpex.Field.help_text(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} diff --git a/lib/backpex/fields/boolean.ex b/lib/backpex/fields/boolean.ex index 9345db88d..3f0e71e33 100644 --- a/lib/backpex/fields/boolean.ex +++ b/lib/backpex/fields/boolean.ex @@ -42,6 +42,7 @@ defmodule Backpex.Fields.Boolean do - + <:trigger class={[ "input block h-fit w-full p-2", @@ -154,12 +154,13 @@ defmodule Backpex.Fields.HasMany do aria_labelledby={Map.get(assigns, :aria_labelledby)} >
-

{@prompt}

+

{@prompt}

<.badge :for={{label, value} <- @selected} live_resource={@live_resource} label={label} value={value} + readonly={@readonly} name={@name} />
@@ -302,10 +303,17 @@ defmodule Backpex.Fields.HasMany do end attr :live_resource, :atom, required: true + attr :readonly, :boolean, default: false attr :name, :string, required: true attr :label, :string, required: true attr :value, :string, required: true + defp badge(%{readonly: true} = assigns) do + ~H""" + {@label} + """ + end + defp badge(assigns) do ~H"""
diff --git a/lib/backpex/fields/has_many_through.ex b/lib/backpex/fields/has_many_through.ex index c78ae55b5..6fccaac50 100644 --- a/lib/backpex/fields/has_many_through.ex +++ b/lib/backpex/fields/has_many_through.ex @@ -259,7 +259,7 @@ defmodule Backpex.Fields.HasManyThrough do > {label} - + {Backpex.__("Actions", @live_resource)} @@ -289,7 +289,7 @@ defmodule Backpex.Fields.HasManyThrough do {assigns} /> - +
diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index a0388ec62..6768dce51 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -187,7 +187,7 @@ defmodule Backpex.Fields.InlineCRUD do class="hidden" /> -
+
{Backpex.__("Delete", @live_resource)}
@@ -199,6 +199,7 @@ defmodule Backpex.Fields.InlineCRUD do
diff --git a/lib/backpex/html/core_components.ex b/lib/backpex/html/core_components.ex index 8d31e6858..c8e065bd8 100644 --- a/lib/backpex/html/core_components.ex +++ b/lib/backpex/html/core_components.ex @@ -38,6 +38,7 @@ defmodule Backpex.HTML.CoreComponents do """ attr :id, :string, required: true, doc: "unique identifier for the dropdown" + attr :readonly, :boolean, default: false attr :class, :any, default: nil, doc: "additional classes for the outer container element" slot :trigger, doc: "the trigger element to be used to toggle the dropdown menu" do @@ -64,8 +65,24 @@ defmodule Backpex.HTML.CoreComponents do _trigger -> nil end) + trigger_class = (assigns.trigger && assigns.trigger[:class]) || "" + + trigger_class = + if assigns.readonly do + ["cursor-not-allowed bg-base-200"] ++ + (trigger_class + |> Enum.join(" ") + |> String.split() + |> List.delete("bg-transparent") + |> List.delete("input")) + else + trigger_class + end + + assigns = assign(assigns, trigger_class: trigger_class) + ~H""" -
+
{render_slot(@trigger)}
prepare_field_assigns(field, assigns.translate_error_fun) |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) @@ -205,6 +212,13 @@ defmodule Backpex.HTML.Form do multiple pattern placeholder readonly required rows size step) def currency_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + assigns = + if Map.has_key?(assigns.rest, :disabled) do + assigns + else + put_in(assigns, [:rest, :disabled], Map.get(assigns.rest, :readonly) || false) + end + assigns |> prepare_field_assigns(field, assigns.translate_error_fun) |> assign_new(:name, fn -> field.name end) @@ -286,6 +300,7 @@ defmodule Backpex.HTML.Form do @doc type: :component attr :prompt, :string, required: true, doc: "string that will be shown when no option is selected" + attr :readonly, :boolean, default: false attr :help_text, :string, default: nil, doc: "help text to be displayed below input" attr :not_found_text, :string, required: true, doc: "string that will be shown when there are no options" attr :options, :list, required: true, doc: "a list of options for the select" @@ -309,7 +324,7 @@ defmodule Backpex.HTML.Form do ~H"""
- <.dropdown id={"multi-select-#{@field.id}"} class="w-full"> + <.dropdown id={"multi-select-#{@field.id}"} class="w-full" readonly={@readonly}> <:trigger aria_label={@prompt} aria_labelledby={Map.get(assigns, :aria_labelledby)} @@ -320,13 +335,14 @@ defmodule Backpex.HTML.Form do ]} >
-

{@prompt}

+

{@prompt}

<.multi_select_badge :for={{label, value} <- @selected} live_resource={@live_resource} label={label} value={value} event_target={@event_target} + readonly={@readonly} />
@@ -384,10 +400,17 @@ defmodule Backpex.HTML.Form do end attr :live_resource, :atom, required: true + attr :readonly, :boolean, default: false attr :label, :string, required: true attr :value, :any, required: true attr :event_target, :any, required: true + defp multi_select_badge(%{readonly: true} = assigns) do + ~H""" + {@label} + """ + end + defp multi_select_badge(assigns) do ~H"""
diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 27c0b98a3..6d6f5c126 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -175,7 +175,13 @@ defmodule Backpex.HTML.Resource do |> assign(:field, field) |> assign(:field_options, field_options) |> assign(:type, :form) - |> assign(:readonly, Backpex.Field.readonly?(field_options, assigns)) + + assigns = + if assigns[:readonly] == true do + assigns + else + assign(assigns, :readonly, Backpex.Field.readonly?(field_options, assigns)) + end ~H""" <.live_component From c2ab6477fc67dea7c6649d0d921b8cb60312d1a8 Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Tue, 23 Dec 2025 17:26:10 +0100 Subject: [PATCH 02/12] Support readonly for all fields: changes for PR review --- lib/backpex/fields/belongs_to.ex | 1 + lib/backpex/fields/boolean.ex | 2 +- lib/backpex/fields/currency.ex | 1 + lib/backpex/fields/inline_crud.ex | 1 + lib/backpex/fields/select.ex | 1 + lib/backpex/fields/url.ex | 1 + lib/backpex/html/core_components.ex | 2 +- lib/backpex/html/form.ex | 16 +--------------- lib/backpex/html/resource.ex | 3 ++- 9 files changed, 10 insertions(+), 18 deletions(-) diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index 271b2a462..192a2e112 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -144,6 +144,7 @@ defmodule Backpex.Fields.BelongsTo do options={@options} prompt={@prompt} readonly={@readonly} + disabled={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} help_text={Backpex.Field.help_text(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} diff --git a/lib/backpex/fields/boolean.ex b/lib/backpex/fields/boolean.ex index 3f0e71e33..c33f1782b 100644 --- a/lib/backpex/fields/boolean.ex +++ b/lib/backpex/fields/boolean.ex @@ -42,7 +42,7 @@ defmodule Backpex.Fields.Boolean do
diff --git a/lib/backpex/html/core_components.ex b/lib/backpex/html/core_components.ex index c8e065bd8..b9c70dbba 100644 --- a/lib/backpex/html/core_components.ex +++ b/lib/backpex/html/core_components.ex @@ -38,7 +38,7 @@ defmodule Backpex.HTML.CoreComponents do """ attr :id, :string, required: true, doc: "unique identifier for the dropdown" - attr :readonly, :boolean, default: false + attr :readonly, :boolean, default: false, doc: "whether the dropdown is readonly" attr :class, :any, default: nil, doc: "additional classes for the outer container element" slot :trigger, doc: "the trigger element to be used to toggle the dropdown menu" do diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index a141eef4e..9ef09fa2a 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -47,13 +47,6 @@ defmodule Backpex.HTML.Form do slot :inner_block def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do - assigns = - if Map.has_key?(assigns.rest, :disabled) do - assigns - else - put_in(assigns, [:rest, :disabled], Map.get(assigns.rest, :readonly) || false) - end - assigns |> prepare_field_assigns(field, assigns.translate_error_fun) |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) @@ -212,13 +205,6 @@ defmodule Backpex.HTML.Form do multiple pattern placeholder readonly required rows size step) def currency_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do - assigns = - if Map.has_key?(assigns.rest, :disabled) do - assigns - else - put_in(assigns, [:rest, :disabled], Map.get(assigns.rest, :readonly) || false) - end - assigns |> prepare_field_assigns(field, assigns.translate_error_fun) |> assign_new(:name, fn -> field.name end) @@ -300,7 +286,7 @@ defmodule Backpex.HTML.Form do @doc type: :component attr :prompt, :string, required: true, doc: "string that will be shown when no option is selected" - attr :readonly, :boolean, default: false + attr :readonly, :boolean, default: false, doc: "whether the dropdown is readonly" attr :help_text, :string, default: nil, doc: "help text to be displayed below input" attr :not_found_text, :string, required: true, doc: "string that will be shown when there are no options" attr :options, :list, required: true, doc: "a list of options for the select" diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 6d6f5c126..107f203ed 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -176,8 +176,9 @@ defmodule Backpex.HTML.Resource do |> assign(:field_options, field_options) |> assign(:type, :form) + # this is needed to apply `:readonly` to individual fields in `Backpex.Fields.InlineCRUD` assigns = - if assigns[:readonly] == true do + if assigns[:readonly] do assigns else assign(assigns, :readonly, Backpex.Field.readonly?(field_options, assigns)) From 975e7b4a7253120ffc3bcab2b374b44152dcb574 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:48:48 +0200 Subject: [PATCH 03/12] Fix readonly guide link label in field.ex docstring --- lib/backpex/field.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 326ab34c0..486ec22f9 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -12,7 +12,7 @@ defmodule Backpex.Field do required: true ], readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + doc: "Sets the field to readonly. Also see the [readonly](/guides/fields/readonly.md) guide.", type: {:or, [:boolean, {:fun, 1}]}, default: false ], From 09929fa45dbbe7ac67eea5e9bac47fc4f6fcb26c Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:50:30 +0200 Subject: [PATCH 04/12] Render dropdown readonly as inert div instead of fake button Previously the readonly dropdown kept role="button", tabindex=0, and aria-haspopup="true" while stripping the menu, producing a focusable element that announces a popup that isn't there. Also removed the fragile class-token-surgery that reached into caller class lists to delete "input" and "bg-transparent" strings; callers now build their own readonly-aware class lists using Phoenix's list syntax. --- lib/backpex/fields/has_many.ex | 8 +-- lib/backpex/html/core_components.ex | 75 +++++++++++++---------------- lib/backpex/html/form.ex | 8 +-- 3 files changed, 44 insertions(+), 47 deletions(-) diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index 43af5b71a..874f036aa 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -147,9 +147,11 @@ defmodule Backpex.Fields.HasMany do <:trigger class={[ - "input block h-fit w-full p-2", - @errors == [] && "bg-transparent", - @errors != [] && "input-error bg-error/10" + "block h-fit w-full p-2", + not @readonly && "input", + not @readonly && @errors == [] && "bg-transparent", + not @readonly && @errors != [] && "input-error bg-error/10", + @readonly && "cursor-not-allowed bg-base-200" ]} aria_labelledby={Map.get(assigns, :aria_labelledby)} > diff --git a/lib/backpex/html/core_components.ex b/lib/backpex/html/core_components.ex index 73867c4cf..da47d00f7 100644 --- a/lib/backpex/html/core_components.ex +++ b/lib/backpex/html/core_components.ex @@ -63,49 +63,42 @@ defmodule Backpex.HTML.CoreComponents do _trigger -> nil end) - trigger_class = (assigns.trigger && assigns.trigger[:class]) || "" - - trigger_class = - if assigns.readonly do - ["cursor-not-allowed bg-base-200"] ++ - (trigger_class - |> Enum.join(" ") - |> String.split() - |> List.delete("bg-transparent") - |> List.delete("input")) - else - trigger_class - end - - assigns = assign(assigns, trigger_class: trigger_class) - - ~H""" -
-
- {render_slot(@trigger)} + if assigns.readonly do + ~H""" +
+
+ {render_slot(@trigger)} +
+ """ + else + ~H""" +
+
+ {render_slot(@trigger)} +
-
- {render_slot(@menu)} +
+ {render_slot(@menu)} +
-
- """ + """ + end end end diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index f40a42c27..557950255 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -317,9 +317,11 @@ defmodule Backpex.HTML.Form do aria_label={@prompt} aria_labelledby={Map.get(assigns, :aria_labelledby)} class={[ - "input block h-fit w-full p-2", - @errors == [] && "bg-transparent", - @errors != [] && "input-error bg-error/10" + "block h-fit w-full p-2", + not @readonly && "input", + not @readonly && @errors == [] && "bg-transparent", + not @readonly && @errors != [] && "input-error bg-error/10", + @readonly && "cursor-not-allowed bg-base-200" ]} >
From 83e0644d913f75ef1b64f1959057d9fe2a484e4b Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:52:16 +0200 Subject: [PATCH 05/12] Gate Upload field readonly for drop target, cancel buttons, and link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop target's phx-drop-target attribute was not gated on readonly, so drag-and-drop still started uploads. Cancel buttons for in-progress and existing entries remained clickable, letting users mutate file state. The "Upload a file" rendered as a dead link in readonly mode — now a plain with the interactive classes removed. --- lib/backpex/fields/upload.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/backpex/fields/upload.ex b/lib/backpex/fields/upload.ex index b48676389..746048845 100644 --- a/lib/backpex/fields/upload.ex +++ b/lib/backpex/fields/upload.ex @@ -662,7 +662,7 @@ defmodule Backpex.Fields.Upload do id={"#{@name}-drop-target"} class="w-full max-w-lg" phx-hook={not @readonly && "BackpexDragHover"} - phx-drop-target={if @uploads_allowed, do: @upload.ref} + phx-drop-target={if @uploads_allowed and not @readonly, do: @upload.ref} >
+ {Backpex.__("Upload a file", @live_resource)} + + {Backpex.__("Upload a file", @live_resource)} + <.live_file_input :if={@uploads_allowed and not @readonly} upload={@upload} @@ -704,6 +707,7 @@ defmodule Backpex.Fields.Upload do

{Map.get(entry, :client_name)}

From da1c844e9ae63f7638745412e35c56fe39e5a880 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:41:37 +0200 Subject: [PATCH 11/12] Document readonly behavior in Upload, InlineCRUD, HasManyThrough moduledocs These three fields render readonly with custom UI changes that hide or disable elements beyond the standard readonly/disabled attribute. Added short Readonly sections to the moduledocs so users discover this behavior without reading the guide. --- lib/backpex/fields/has_many_through.ex | 6 ++++++ lib/backpex/fields/inline_crud.ex | 6 ++++++ lib/backpex/fields/upload.ex | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/lib/backpex/fields/has_many_through.ex b/lib/backpex/fields/has_many_through.ex index 9dcfd307b..a540f5bc4 100644 --- a/lib/backpex/fields/has_many_through.ex +++ b/lib/backpex/fields/has_many_through.ex @@ -75,6 +75,12 @@ defmodule Backpex.Fields.HasManyThrough do end The field requires a [`Ecto.Schema.has_many/3`](https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3) relation with a mandatory `through` option in the main schema. Any extra column in the pivot table besides the relational id's must be mapped in the `pivot_fields` option or given a default value. + + ## Readonly + + When the field is readonly, the Actions column (edit/remove) is hidden, the "new relational" + button is disabled, and any pivot and select inputs inside the edit-relation modal are disabled. + See the [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema import Ecto.Query diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index 0eda18937..d304108e6 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -88,6 +88,12 @@ defmodule Backpex.Fields.InlineCRUD do } ] end + + ## Readonly + + When the field is readonly, each nested row's child fields render as readonly, the per-row delete + checkbox is hidden entirely, and the add-row control is hidden entirely. See the + [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema diff --git a/lib/backpex/fields/upload.ex b/lib/backpex/fields/upload.ex index 746048845..0b90a35e9 100644 --- a/lib/backpex/fields/upload.ex +++ b/lib/backpex/fields/upload.ex @@ -600,6 +600,12 @@ defmodule Backpex.Fields.Upload do ... }) + ## Readonly + + When the field is readonly, the drop target and the "Upload a file" link are disabled, and the + cancel/remove buttons on both pending and existing entries are hidden. The list of existing files + is still displayed so users can see the current value. See the + [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema alias Backpex.HTML.Form, as: BackpexForm From 46032cc31d5a0cb0b0117fd2b9d3511329c9bedb Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:46:13 +0200 Subject: [PATCH 12/12] Add component tests for dropdown/1 and multi_select/1 readonly Covers the readonly-branch HTML surface of both components: dropdown has no role/tabindex/aria-haspopup/menu when readonly; multi_select prompt uses /60 contrast and badges render without primary color or remove controls. Sets up test/html/ as the home for future HTML component tests (the repo previously had no component-level coverage). --- test/html/core_components_test.exs | 85 ++++++++++++++++++++++++++ test/html/form_test.exs | 96 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 test/html/core_components_test.exs create mode 100644 test/html/form_test.exs diff --git a/test/html/core_components_test.exs b/test/html/core_components_test.exs new file mode 100644 index 000000000..96c97747c --- /dev/null +++ b/test/html/core_components_test.exs @@ -0,0 +1,85 @@ +defmodule Backpex.HTML.CoreComponentsTest do + use ExUnit.Case, async: true + + import Phoenix.Component + import Phoenix.LiveViewTest + + import Backpex.HTML.CoreComponents + + # A thin wrapper so we can exercise the `dropdown/1` slots via render_component/2. + defmodule TestComponent do + use Phoenix.Component + + import Backpex.HTML.CoreComponents + + attr :readonly, :boolean, default: false + attr :class, :any, default: nil + + def test_dropdown(assigns) do + ~H""" + <.dropdown id="test-dd" class={@class} readonly={@readonly}> + <:trigger aria_label="open" class="trigger-class">Trigger + <:menu>Menu content + + """ + end + end + + describe "dropdown/1" do + test "renders dropdown class and trigger role in non-readonly mode" do + html = render_component(&TestComponent.test_dropdown/1, readonly: false, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + trigger = LazyHTML.query(doc, "#test-dd-trigger") + menu = LazyHTML.query(doc, "#test-dd-menu") + + assert LazyHTML.attribute(outer, "class") == ["dropdown w-full"] + + assert LazyHTML.attribute(trigger, "role") == ["button"] + assert LazyHTML.attribute(trigger, "tabindex") == ["0"] + assert LazyHTML.attribute(trigger, "aria-haspopup") == ["true"] + assert LazyHTML.attribute(trigger, "aria-label") == ["open"] + + # menu div is present + assert Enum.count(menu) == 1 + end + + test "renders inert div without interactive attrs in readonly mode" do + html = render_component(&TestComponent.test_dropdown/1, readonly: true, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + trigger = LazyHTML.query(doc, "#test-dd-trigger") + menu = LazyHTML.query(doc, "#test-dd-menu") + + # User-supplied class is still passed through, but the dropdown class is not. + [outer_class] = LazyHTML.attribute(outer, "class") + refute outer_class =~ "dropdown" + assert outer_class =~ "w-full" + + # No interactive attributes on the trigger in readonly mode. + assert LazyHTML.attribute(trigger, "role") == [] + assert LazyHTML.attribute(trigger, "tabindex") == [] + assert LazyHTML.attribute(trigger, "aria-haspopup") == [] + assert LazyHTML.attribute(trigger, "aria-label") == [] + assert LazyHTML.attribute(trigger, "aria-labelledby") == [] + + # Menu div is not rendered in readonly mode. + assert Enum.empty?(menu) + end + + test "passes through the user-supplied class on the outer wrapper in both modes" do + for readonly <- [false, true] do + html = render_component(&TestComponent.test_dropdown/1, readonly: readonly, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + [outer_class] = LazyHTML.attribute(outer, "class") + + assert outer_class =~ "w-full", + "expected w-full in outer class for readonly=#{readonly}, got #{inspect(outer_class)}" + end + end + end +end diff --git a/test/html/form_test.exs b/test/html/form_test.exs new file mode 100644 index 000000000..c663d07c6 --- /dev/null +++ b/test/html/form_test.exs @@ -0,0 +1,96 @@ +defmodule Backpex.HTML.FormTest do + use ExUnit.Case, async: true + + import Phoenix.Component + import Phoenix.LiveViewTest + + alias Backpex.HTML.Form, as: BackpexForm + + # Build a bare `Phoenix.HTML.FormField` with the minimum needed for multi_select/1: + # `field.id` (for the dropdown wrapper id), `field.name` (for the search/hidden inputs) + # and `field.errors`. + defp build_field do + form = to_form(%{"tags" => ""}, as: nil) + + %Phoenix.HTML.FormField{ + id: "tags", + name: "tags", + errors: [], + field: :tags, + form: form, + value: "" + } + end + + defp base_assigns(overrides) do + defaults = [ + prompt: "Select an option", + not_found_text: "No options found", + options: [], + search_input: "", + event_target: nil, + field_options: %{}, + field: build_field(), + selected: [], + show_select_all: true, + show_more: false + ] + + Keyword.merge(defaults, overrides) + end + + describe "multi_select/1" do + test "readonly prompt uses /60 contrast class" do + assigns = base_assigns(readonly: true, selected: []) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # The prompt is the

rendered when `@selected == []`. + prompt = LazyHTML.query(doc, "p") + [prompt_class] = LazyHTML.attribute(prompt, "class") + + assert prompt_class =~ "text-base-content/60" + end + + test "readonly badge has no remove button or badge-primary class" do + assigns = base_assigns(readonly: true, selected: [{"Elixir", "elixir"}]) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # In readonly mode the badge is a . + badge = LazyHTML.query(doc, "span.badge") + [badge_class] = LazyHTML.attribute(badge, "class") + + assert badge_class =~ "badge" + refute badge_class =~ "badge-primary" + + # No interactive remove control inside the badge. + refute html =~ ~s(phx-click="toggle-option") + # And no buttons rendered as part of the badge markup. (The dropdown itself does + # not render a menu in readonly mode, so there should be no phx-click remove.) + remove_buttons = LazyHTML.query(doc, "span.badge [phx-click]") + assert Enum.empty?(remove_buttons) + end + + test "non-readonly badge includes badge-primary and remove affordance" do + assigns = base_assigns(readonly: false, selected: [{"Elixir", "elixir"}]) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # In non-readonly mode the badge is a

. + badge = LazyHTML.query(doc, "div.badge") + [badge_class] = LazyHTML.attribute(badge, "class") + + assert badge_class =~ "badge-primary" + + # The remove affordance is a div with role="button" and phx-click="toggle-option" + # inside the badge. + assert html =~ ~s(phx-click="toggle-option") + remove = LazyHTML.query(doc, ~s(div.badge [phx-click="toggle-option"])) + refute Enum.empty?(remove) + end + end +end