Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
21cfefe
Support readonly for all fields
gmazzamuto Nov 24, 2025
c2ab647
Support readonly for all fields: changes for PR review
gmazzamuto Dec 23, 2025
d2fd7eb
Merge remote-tracking branch 'origin/develop' into feature/readonly-f…
Flo0807 Apr 22, 2026
975e7b4
Fix readonly guide link label in field.ex docstring
Flo0807 Apr 22, 2026
09929fa
Render dropdown readonly as inert div instead of fake button
Flo0807 Apr 22, 2026
83e0644
Gate Upload field readonly for drop target, cancel buttons, and link
Flo0807 Apr 22, 2026
42517c0
Improve readonly prompt contrast to meet WCAG AA
Flo0807 Apr 22, 2026
284ef05
Drop redundant disabled={@readonly} from native-readonly inputs
Flo0807 Apr 22, 2026
adbb2fc
Hide InlineCRUD delete and add controls entirely when readonly
Flo0807 Apr 22, 2026
bf3d8f2
Thread readonly through has_many_through relational sub-components
Flo0807 Apr 22, 2026
aa0f6f5
Update readonly guide for all fields supported after the PR
Flo0807 Apr 22, 2026
da1c844
Document readonly behavior in Upload, InlineCRUD, HasManyThrough modu…
Flo0807 Apr 23, 2026
46032cc
Add component tests for dropdown/1 and multi_select/1 readonly
Flo0807 Apr 23, 2026
d0b9c29
Merge branch 'develop' into feature/readonly-fields
gmazzamuto Jul 12, 2026
9420122
Fix readonly InlineCRUD
gmazzamuto Jul 9, 2026
67cf1ba
Merge latest develop into readonly fields branch
Flo0807 Sep 11, 2026
c394585
Preserve readonly Boolean values in nested forms
Flo0807 Sep 11, 2026
8ced2b4
Unify readonly field styling and preserve accessible labels
Flo0807 Sep 11, 2026
412f325
Document readonly field changes for v0.21
Flo0807 Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions guides/fields/readonly.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,64 @@
# 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.

## Supported fields

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.Boolean` — renders as a disabled toggle
- `Backpex.Fields.BelongsTo`

**Custom readonly rendering**

A few fields need tailored behavior beyond a single attribute:

- `Backpex.Fields.MultiSelect` — dropdown is rendered as a labelled, non-interactive group; selected badges lose the remove control
- `Backpex.Fields.HasMany` — dropdown is rendered as a labelled, non-interactive group; selected badges lose the remove control
- `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.

## Appearance and accessibility

Readonly inputs use the active theme's `base-200` background, `base-300` border, and
`base-content` text color. Native selects and custom dropdowns use the same treatment,
so non-editable values remain readable in light and dark themes. Validation errors
retain their error colors.

Text-like inputs keep native `readonly` behavior: they remain keyboard-focusable,
retain a visible focus outline, and allow text selection and copying. Controls that
cannot use native `readonly` remain disabled or render as non-interactive groups.
The visual treatment does not change which values are submitted with the form.

## 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
Expand Down Expand Up @@ -67,7 +108,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}
/>
</Layout.field_container>
</div>
Expand Down
30 changes: 30 additions & 0 deletions guides/upgrading/v0.21.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Upgrading to v0.21

## Bump Your Deps

Update Backpex to the latest version:

```elixir
defp deps do
[
{:backpex, "~> 0.21.0"}
]
end
```

## Readonly fields

`readonly` is now a global field option. Existing configurations keep working,
but check these changes when upgrading:

- Disabled checkboxes and toggles no longer submit a hidden `false` value.
Custom form handlers must preserve existing values when these parameters are absent.
- Nested fields inherit a readonly parent; a child's `readonly: false` cannot override it.
- Review custom CSS and DOM selectors for the updated readonly styling and
non-interactive dropdown markup.
- Code inspecting field schemas must include `Backpex.Field.default_config_schema/0`
to find the global `readonly` option.

See the [Readonly guide](../fields/readonly.md) for supported fields and behavior,
and [Readonly for custom fields](../fields/readonly.md#readonly-for-custom-fields)
for custom implementations.
5 changes: 5 additions & 0 deletions lib/backpex/field.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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: """
Expand Down
2 changes: 2 additions & 0 deletions lib/backpex/fields/belongs_to.ex
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,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)}
Expand Down
1 change: 1 addition & 0 deletions lib/backpex/fields/boolean.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ defmodule Backpex.Fields.Boolean do
<BackpexForm.input
type="toggle"
field={@form[@name]}
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)}
Expand Down
1 change: 1 addition & 0 deletions lib/backpex/fields/currency.ex
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ defmodule Backpex.Fields.Currency do
</:label>
<BackpexForm.currency_input
field={@form[@name]}
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)}
Expand Down
5 changes: 0 additions & 5 deletions lib/backpex/fields/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ defmodule Backpex.Fields.Date 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}]}
]
]

Expand Down Expand Up @@ -114,7 +110,6 @@ defmodule Backpex.Fields.Date 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)}
/>
</Layout.field_container>
Expand Down
5 changes: 0 additions & 5 deletions lib/backpex/fields/date_time.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,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}]}
]
]

Expand Down Expand Up @@ -114,7 +110,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)}
/>
</Layout.field_container>
Expand Down
5 changes: 0 additions & 5 deletions lib/backpex/fields/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,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}]}
]
]

Expand Down Expand Up @@ -56,7 +52,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)}
/>
</Layout.field_container>
Expand Down
31 changes: 24 additions & 7 deletions lib/backpex/fields/has_many.ex
Original file line number Diff line number Diff line change
Expand Up @@ -142,21 +142,31 @@ defmodule Backpex.Fields.HasMany do
<div id={"has-many-#{@name}"}>
<Layout.field_container>
<:label :if={not @hide_label} align={Backpex.Field.align_label(@field_options, assigns)}>
<Layout.input_label as="span" text={@field_options[:label]} />
<Layout.input_label id={"#{@form[@name].id}-label"} as="span" text={@field_options[:label]} />
</:label>

<Backpex.HTML.CoreComponents.dropdown id={"has-many-dropdown-#{@name}"} class="w-full">
<Backpex.HTML.CoreComponents.dropdown id={"has-many-dropdown-#{@name}"} class="w-full" readonly={@readonly}>
<: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 && "rounded-field border-(length:--border) border min-h-10",
Backpex.HTML.Form.readonly_input_class(@readonly),
@readonly && @errors != [] && "border-error bg-error/10"
]}
aria_labelledby={Map.get(assigns, :aria_labelledby)}
aria_labelledby={Map.get(assigns, :aria_labelledby) || "#{@form[@name].id}-label"}
>
<div class="flex h-full w-full flex-wrap items-center gap-1 px-2">
<p :if={@selected == []} class="p-0.5 text-sm">{@prompt}</p>
<.badge :for={{label, value} <- @selected} label={label} value={value} name={@name} />
<.badge
:for={{label, value} <- @selected}
label={label}
value={value}
readonly={@readonly}
name={@name}
/>
</div>
</:trigger>
<:menu class="w-full overflow-y-auto">
Expand Down Expand Up @@ -296,10 +306,17 @@ defmodule Backpex.Fields.HasMany do
"""
end

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"""
<span class="badge badge-sm badge-soft">{@label}</span>
"""
end

defp badge(assigns) do
~H"""
<div class="badge badge-sm badge-soft badge-primary pointer-events-auto pr-0">
Expand Down
41 changes: 37 additions & 4 deletions lib/backpex/fields/has_many_through.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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
Expand Down Expand Up @@ -260,7 +266,7 @@ defmodule Backpex.Fields.HasManyThrough do
>
{label}
</th>
<th>
<th :if={not @readonly}>
<span class="sr-only">{Backpex.__("Actions", @live_resource)}</span>
</th>
</tr>
Expand Down Expand Up @@ -290,7 +296,7 @@ defmodule Backpex.Fields.HasManyThrough do
{assigns}
/>
</td>
<td>
<td :if={not @readonly}>
<div class="flex items-center space-x-2">
<button
class="cursor-pointer"
Expand Down Expand Up @@ -346,8 +352,15 @@ defmodule Backpex.Fields.HasManyThrough do
field_options={@field}
owner_key={@owner_key}
options={@options}
readonly={@readonly}
/>
<.pivot_field
:for={{name, _field_options} <- @field_options.pivot_fields}
name={name}
form={e}
readonly={@readonly}
{assigns}
/>
<.pivot_field :for={{name, _field_options} <- @field_options.pivot_fields} name={name} form={e} {assigns} />
</div>
<div class="bg-base-200 flex justify-end space-x-4 px-6 py-3">
<button
Expand All @@ -361,7 +374,13 @@ defmodule Backpex.Fields.HasManyThrough do
</div>
</.modal>

<button type="button" class="btn btn-sm btn-outline btn-primary" phx-click="new-relational" phx-target={@myself}>
<button
disabled={@readonly}
type="button"
class="btn btn-sm btn-outline btn-primary"
phx-click="new-relational"
phx-target={@myself}
>
{@relational_title}
</button>

Expand Down Expand Up @@ -449,6 +468,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

Expand Down Expand Up @@ -538,6 +561,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"""
<Layout.field_container>
Expand All @@ -548,6 +579,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)}
Expand Down
Loading