diff --git a/guides/fields/readonly.md b/guides/fields/readonly.md index 68aabc6a2..13ab1c486 100644 --- a/guides/fields/readonly.md +++ b/guides/fields/readonly.md @@ -1,6 +1,6 @@ # Readonly -Fields can be configured to be readonly. In edit view, these fields are rendered with the additional HTML attributes `readonly` and `disabled`, ensuring that users cannot interact with the field or change its value. +Fields can be configured to be readonly. In edit view, readonly fields prevent users from interacting with the field or changing its value, while still displaying the current value. In index view, if readonly and index editable are both set to true, forms will be rendered with the `readonly` HTML attribute. @@ -8,16 +8,45 @@ In index view, if readonly and index editable are both set to true, forms will b On index view, readonly is supported for all fields with the index editable option (see [Index Edit](index-edit.md)). -On edit view, readonly is supported for: -- `Backpex.Fields.Date` -- `Backpex.Fields.DateTime` -- `Backpex.Fields.Number` +On edit view, `readonly` is a global field option defined on `Backpex.Field`, so every built-in field type inherits it. It accepts either a `boolean` or a function `(assigns -> boolean)`. + +Built-in fields render readonly using one of three strategies: + +**Native `readonly` (text-like inputs)** + +These fields render the browser's native `readonly` attribute on their input, so the value is still focusable and selectable but cannot be changed: + - `Backpex.Fields.Text` - `Backpex.Fields.Textarea` +- `Backpex.Fields.Number` +- `Backpex.Fields.Date` +- `Backpex.Fields.DateTime` +- `Backpex.Fields.Time` +- `Backpex.Fields.Email` +- `Backpex.Fields.URL` +- `Backpex.Fields.Currency` + +**`disabled` (control-style inputs)** + +Native `readonly` does not apply to these control types, so they render as `disabled` instead: + +- `Backpex.Fields.Select` +- `Backpex.Fields.MultiSelect` +- `Backpex.Fields.Boolean` — renders as a disabled toggle +- `Backpex.Fields.BelongsTo` +- `Backpex.Fields.HasMany` — dropdown is rendered as an inert element; selected badges lose the remove control + +**Custom readonly rendering** + +A few fields need tailored behavior beyond a single attribute: + +- `Backpex.Fields.Upload` — the drop target and "Upload a file" link are disabled, the cancel/remove buttons on pending and existing entries are hidden, and the existing-file list is still displayed so users can see what is attached. +- `Backpex.Fields.InlineCRUD` — nested row fields become readonly, and the per-row delete checkbox and the add-row control are hidden entirely. +- `Backpex.Fields.HasManyThrough` — the Actions column (edit/remove buttons) is hidden, the "new relational" button is disabled, and pivot and select inputs inside the modal are rendered as disabled. ## Configuration -To enable readonly for a field, you need to set the `readonly` option to true in the field configuration. This key must contain either a boolean value or a function that returns a boolean value. +To enable readonly for a field, you need to set the `readonly` option in the field configuration. This key must contain either a boolean value or a function that returns a boolean value. ```elixir # in your resource configuration file @@ -67,7 +96,6 @@ def render_form(assigns) do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} /> diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 8c1eae8a6..486ec22f9 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 [readonly](/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 8165341bf..581814a37 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -143,6 +143,8 @@ defmodule Backpex.Fields.BelongsTo do field={@form[@owner_key]} 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 9345db88d..c33f1782b 100644 --- a/lib/backpex/fields/boolean.ex +++ b/lib/backpex/fields/boolean.ex @@ -42,6 +42,7 @@ defmodule Backpex.Fields.Boolean do diff --git a/lib/backpex/fields/date_time.ex b/lib/backpex/fields/date_time.ex index 31c12d5bc..90531f4e9 100644 --- a/lib/backpex/fields/date_time.ex +++ b/lib/backpex/fields/date_time.ex @@ -17,10 +17,6 @@ defmodule Backpex.Fields.DateTime do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -113,7 +109,6 @@ defmodule Backpex.Fields.DateTime do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/email.ex b/lib/backpex/fields/email.ex index adcf273cd..c939f0ea0 100644 --- a/lib/backpex/fields/email.ex +++ b/lib/backpex/fields/email.ex @@ -11,10 +11,6 @@ defmodule Backpex.Fields.Email do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -55,7 +51,6 @@ defmodule Backpex.Fields.Email do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index bcd6f3223..ce254a6a8 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -144,22 +144,25 @@ 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)} >
-

{@prompt}

+

{@prompt}

<.badge :for={{label, value} <- @selected} live_resource={@live_resource} label={label} value={value} + readonly={@readonly} name={@name} />
@@ -302,10 +305,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 b13583dcb..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 @@ -259,7 +265,7 @@ defmodule Backpex.Fields.HasManyThrough do > {label} - + {Backpex.__("Actions", @live_resource)} @@ -289,7 +295,7 @@ defmodule Backpex.Fields.HasManyThrough do {assigns} /> - +
@@ -448,6 +467,10 @@ defmodule Backpex.Fields.HasManyThrough do @impl Backpex.Field def association?(_field), do: true + attr :name, :atom, required: true + attr :form, :any, required: true + attr :readonly, :boolean, default: false + defp pivot_field(assigns) do name = assigns.name @@ -537,6 +560,14 @@ defmodule Backpex.Fields.HasManyThrough do items end + attr :form, :any, required: true + attr :hide_label, :boolean, required: true + attr :label, :string, required: true + attr :field_options, :any, required: true + attr :owner_key, :atom, required: true + attr :options, :list, required: true + attr :readonly, :boolean, default: false + defp select_relational_field(assigns) do ~H""" @@ -547,6 +578,8 @@ defmodule Backpex.Fields.HasManyThrough do type="select" field={@form[@owner_key]} options={@options} + disabled={@readonly} + aria-disabled={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index b6d8051d9..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 @@ -209,7 +215,7 @@ defmodule Backpex.Fields.InlineCRUD do )}
-
+
diff --git a/lib/backpex/fields/select.ex b/lib/backpex/fields/select.ex index 1df15afe4..d5fb3d6cc 100644 --- a/lib/backpex/fields/select.ex +++ b/lib/backpex/fields/select.ex @@ -77,6 +77,8 @@ defmodule Backpex.Fields.Select do field={@form[@name]} 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/text.ex b/lib/backpex/fields/text.ex index 0612d206e..70ac5aafa 100644 --- a/lib/backpex/fields/text.ex +++ b/lib/backpex/fields/text.ex @@ -11,10 +11,6 @@ defmodule Backpex.Fields.Text do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -55,7 +51,6 @@ defmodule Backpex.Fields.Text do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index da9c56793..b8c23baf5 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -16,10 +16,6 @@ defmodule Backpex.Fields.Textarea do doc: "Number of visible text lines for the control.", type: :non_neg_integer, default: 2 - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -65,7 +61,6 @@ defmodule Backpex.Fields.Textarea do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/time.ex b/lib/backpex/fields/time.ex index 7c01e9438..6fb406ca3 100644 --- a/lib/backpex/fields/time.ex +++ b/lib/backpex/fields/time.ex @@ -17,10 +17,6 @@ defmodule Backpex.Fields.Time do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -86,7 +82,6 @@ defmodule Backpex.Fields.Time do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/upload.ex b/lib/backpex/fields/upload.ex index 3c03b2850..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 @@ -661,22 +667,31 @@ defmodule Backpex.Fields.Upload do

{Map.get(entry, :client_name)}

diff --git a/lib/backpex/html/core_components.ex b/lib/backpex/html/core_components.ex index fdb84fff6..da47d00f7 100644 --- a/lib/backpex/html/core_components.ex +++ b/lib/backpex/html/core_components.ex @@ -36,6 +36,7 @@ defmodule Backpex.HTML.CoreComponents do """ attr :id, :string, required: true, doc: "unique identifier for the dropdown" + 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 @@ -62,32 +63,42 @@ defmodule Backpex.HTML.CoreComponents do _trigger -> nil end) - ~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 d1a1b592e..bd72d659f 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -288,6 +288,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, 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" @@ -311,24 +312,27 @@ 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)} 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" ]} >
-

{@prompt}

+

{@prompt}

<.multi_select_badge :for={{label, value} <- @selected} live_resource={@live_resource} label={label} value={value} event_target={@event_target} + readonly={@readonly} />
@@ -386,10 +390,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 24311f438..f98059ae6 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -175,7 +175,14 @@ defmodule Backpex.HTML.Resource do |> assign(:field, field) |> assign(:field_options, field_options) |> assign(:type, :form) - |> assign(:readonly, Backpex.Field.readonly?(field_options, assigns)) + + # this is needed to apply `:readonly` to individual fields in `Backpex.Fields.InlineCRUD` + assigns = + if assigns[:readonly] do + assigns + else + assign(assigns, :readonly, Backpex.Field.readonly?(field_options, assigns)) + end ~H""" <.live_component 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