Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 20 additions & 2 deletions assets/js/hooks/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export default {
[this.refs.listbox, 'click', this.handleListboxClick.bind(this)],
[this.el, 'keydown', this.handleKeydown.bind(this)],
[this.refs.listbox, 'phx:show-start', this.handleShowStart.bind(this)],
[this.refs.listbox, 'phx:hide-end', this.handleHideEnd.bind(this)]
[this.refs.listbox, 'phx:hide-end', this.handleHideEnd.bind(this)],
[this.el, 'prima:listbox:set-value', this.handleSetValue.bind(this)]
]

this.listeners.forEach(([element, event, handler]) => {
Expand Down Expand Up @@ -275,11 +276,28 @@ export default {
// User-driven selection: updates the form and displayed values
// instantly, ahead of any server round-trip.
selectOption(option) {
this.applyValue(option, { notify: true })
},

// Like a native <select>'s `.value =`, this fires no events unless `notify`
// is set. Values with no matching option are ignored.
handleSetValue(e) {
const { value, notify = false } = e.detail || {}
if (value === null || value === undefined) return

const option = this.findOptionByValue(String(value))
if (option) this.applyValue(option, { notify })
},

applyValue(option, { notify }) {
const value = option.getAttribute('data-value')

if (this.refs.valueInput.value !== value) {
this.refs.valueInput.value = value
this.refs.valueInput.dispatchEvent(new Event('input', { bubbles: true }))
if (notify) {
this.refs.valueInput.dispatchEvent(new Event('input', { bubbles: true }))
this.refs.valueInput.dispatchEvent(new Event('change', { bubbles: true }))
}
}

this.syncSelectedState(option)
Expand Down
29 changes: 27 additions & 2 deletions demo/lib/demo_web/live/demo_live/listbox_page.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@
<p>
<code>.listbox</code>
renders a hidden <code>&lt;input&gt;</code>
with the given <code>name</code>. Selecting an option updates the input and dispatches an
with the given <code>name</code>. Selecting an option updates the input and dispatches
<code>input</code>
event. This makes a parent form's <code>phx-change</code>
and <code>change</code>
events. This makes a parent form's <code>phx-change</code>
event run as it does for a native form field.
Use <code>.listbox_value</code>
to render the current value or a placeholder in the trigger.
Expand Down Expand Up @@ -108,6 +109,30 @@
trigger button in the server-rendered HTML.
</p>

<h2>Setting the Value Client-Side</h2>
<p>
Use <code>Prima.Listbox.JS.set_value/3</code>
to change the value without a server round-trip, for example to reset a field that
client-side code hides. It updates the hidden input, the displayed value and the
selection markers.
</p>

<div class="not-prose">
<.code_example file="listbox/value_setter.html.heex" id="value-setter-listbox-demo" />
</div>

<p>
Like setting <code>.value</code>
on a native <code>&lt;select&gt;</code>, it dispatches no events by default, so
<code>phx-change</code>
doesn't fire and the server keeps its old value. Pass <code>notify: true</code>
to dispatch <code>input</code>
and <code>change</code>
like a user selection. Values that match no option are ignored. From plain JavaScript,
dispatch a <code>prima:listbox:set-value</code>
event on the listbox element with the value in its <code>detail</code>.
</p>

<h2>Keyboard Interaction</h2>
<p>
The listbox provides keyboard navigation that follows standard ARIA patterns.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,10 @@
>
Toggle disabled
</button>
<button
id="set-listbox-pineapple"
type="button"
phx-click={Prima.Listbox.JS.set_value("listbox-form", "Pineapple", notify: true)}
>
Set Pineapple
</button>
45 changes: 45 additions & 0 deletions demo/priv/code_examples/listbox/value_setter.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<div class="flex items-center gap-3">
<.listbox id="value-setter-listbox" name="role" value="editor">
<.listbox_trigger
id="value-setter-listbox-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<.listbox_value>Editor</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>

<.listbox_options
id="value-setter-listbox-options"
class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
:for={{value, label} <- [{"viewer", "Viewer"}, {"editor", "Editor"}, {"admin", "Admin"}]}
id={"value-setter-option-#{value}"}
value={value}
display={label}
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-selected:font-semibold block w-full px-4 py-2 text-sm text-left"
>
{label}
</.listbox_option>
</.listbox_options>
</.listbox>

<button
type="button"
phx-click={Prima.Listbox.JS.set_value("value-setter-listbox", "viewer", notify: false)}
class="rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Reset to Viewer
</button>
</div>
33 changes: 33 additions & 0 deletions demo/test/wallaby/demo_web/listbox_form_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,37 @@ defmodule DemoWeb.ListboxFormIntegrationTest do
|> click(Query.css("#submit-listbox-form"))
|> assert_has(Query.css("#listbox-submission", text: "Submitted: Mango"))
end

describe "setting the value client-side" do
feature "does not fire phx-change by default", %{session: session} do
session
|> visit_fixture("/fixtures/listbox-form", "#listbox-form")
|> execute_script("""
document.getElementById('listbox-form').dispatchEvent(
new CustomEvent('prima:listbox:set-value', {detail: {value: 'Mango'}})
)
""")
|> assert_has(@listbox_value |> Query.text("Mango"))
# A real selection afterwards must be the first and only phx-change.
|> click(@button)
|> click(Query.css("#listbox-form-option-apple"))
|> assert_has(@selection_display |> Query.text("Selected: Apple"))
|> assert_form_change_count(1)
end

feature "Prima.Listbox.JS.set_value with notify fires phx-change once", %{
session: session
} do
session
|> visit_fixture("/fixtures/listbox-form", "#listbox-form")
|> click(Query.css("#set-listbox-pineapple"))
|> assert_has(@listbox_value |> Query.text("Pineapple"))
|> assert_has(@selection_display |> Query.text("Selected: Pineapple"))
# input + change together must count as one phx-change.
|> click(@button)
|> click(Query.css("#listbox-form-option-apple"))
|> assert_has(@selection_display |> Query.text("Selected: Apple"))
|> assert_form_change_count(2)
end
end
end
94 changes: 94 additions & 0 deletions demo/test/wallaby/demo_web/listbox_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ defmodule DemoWeb.ListboxTest do
@options Query.css("#listbox [role=option]")
@listbox_value Query.css("#listbox [data-prima-ref='value']")

defp dispatch_set_value(session, listbox_id, detail) do
execute_script(session, """
document.getElementById(#{Jason.encode!(listbox_id)}).dispatchEvent(
new CustomEvent('prima:listbox:set-value', {detail: #{Jason.encode!(detail)}})
)
""")
end

# Returns and clears the events recorded since the previous call, so each
# assertion only sees what the preceding action dispatched.
defp assert_dispatched_events(session, expected) do
execute_script(
session,
"""
const events = window.listboxEvents;
window.listboxEvents = [];
return events;
""",
fn events -> assert events == expected end
)
end

feature "default trigger has type='button' and aria-haspopup='listbox'", %{session: session} do
session
|> visit_fixture("/fixtures/listbox", "#listbox")
Expand Down Expand Up @@ -238,4 +260,76 @@ defmodule DemoWeb.ListboxTest do
|> assert_has(@disabled_listbox |> Query.visible(false))
end
end

describe "setting the value client-side" do
feature "updates the hidden input, displayed value and selection markers", %{
session: session
} do
session
|> visit_fixture("/fixtures/listbox", "#listbox")
|> dispatch_set_value("listbox", %{value: "apple"})
|> assert_has(@listbox_value |> Query.text("Apple"))
|> assert_has(Query.css("#listbox input[value=apple]", visible: false))
|> assert_has(
Query.css("#listbox-option-apple[aria-selected=true][data-selected]", visible: false)
)
|> assert_missing(
Query.css("#listbox [role=option][aria-selected]:not(#listbox-option-apple)",
visible: :any
)
)
end

feature "dispatches input and change only for user selections and notify: true", %{
session: session
} do
session
|> visit_fixture("/fixtures/listbox", "#listbox")
|> execute_script("""
window.listboxEvents = [];
const input = document.querySelector('#listbox input[name=fruit_choice]');
for (const type of ['input', 'change']) {
input.addEventListener(type, () => window.listboxEvents.push(type));
}
""")
|> click(@button)
|> click(Query.css("#listbox-option-apple"))
|> assert_has(@listbox_value |> Query.text("Apple"))
|> assert_dispatched_events(["input", "change"])
|> dispatch_set_value("listbox", %{value: "cherry"})
|> assert_has(@listbox_value |> Query.text("Cherry"))
|> assert_dispatched_events([])
|> dispatch_set_value("listbox", %{value: "banana", notify: true})
|> assert_has(@listbox_value |> Query.text("Banana"))
|> assert_dispatched_events(["input", "change"])
end

feature "can select a disabled option", %{session: session} do
session
|> visit_fixture("/fixtures/listbox", "#listbox")
|> dispatch_set_value("listbox", %{value: "durian"})
|> assert_has(@listbox_value |> Query.text("Durian"))
|> assert_has(Query.css("#listbox input[value=durian]", visible: false))
|> assert_has(Query.css("#listbox-option-durian[aria-selected=true]", visible: false))
end

feature "ignores values that match no option", %{session: session} do
session
|> visit_fixture("/fixtures/listbox", "#listbox")
|> dispatch_set_value("listbox", %{value: "grape"})
|> dispatch_set_value("listbox", %{value: ""})
|> dispatch_set_value("listbox", %{value: nil})
|> assert_has(@listbox_value |> Query.text("Banana"))
|> assert_has(Query.css("#listbox input[value=banana]", visible: false))
|> assert_has(Query.css("#listbox-option-banana[aria-selected=true]", visible: false))
end

feature "applies to a disabled listbox", %{session: session} do
session
|> visit_fixture("/fixtures/listbox", "#disabled-listbox")
|> dispatch_set_value("disabled-listbox", %{value: "apple"})
|> assert_has(Query.css("#disabled-listbox [data-prima-ref=value]", text: "Apple"))
|> assert_has(Query.css("#disabled-listbox input:disabled[value=apple]", visible: false))
end
end
end
78 changes: 76 additions & 2 deletions lib/prima/listbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ defmodule Prima.Listbox do
## Form Integration

`Listbox` renders a hidden `<input>` with the given `name`. Selecting an option
updates the input's value and dispatches a bubbling `input` event, so a parent
form's `phx-change` fires exactly like it would for a native form field:
updates the input's value and dispatches bubbling `input` and `change` events,
so a parent form's `phx-change` fires exactly like it would for a native form
field:

<form phx-change="form_changed">
<.listbox id="fruit-listbox" name="fruit" value={@selected_fruit}>
Expand Down Expand Up @@ -69,6 +70,34 @@ defmodule Prima.Listbox do
<.listbox_option id="fruit-option-apple" value="apple">Apple</.listbox_option>
</.listbox_options>
</.listbox>

## Setting the Value from Client-Side Code

`Prima.Listbox.JS.set_value/2` sets the value without a server round-trip.
It updates the hidden input, the displayed value and the selection markers:

<button type="button" phx-click={Prima.Listbox.JS.set_value("fruit-listbox", "apple")}>
Reset
</button>

Like setting `.value` on a native `<select>`, this dispatches no events by
default, so a parent form's `phx-change` doesn't fire. Pass `notify: true` to
also dispatch `input` and `change`, exactly like a user selection:

Prima.Listbox.JS.set_value("fruit-listbox", "apple", notify: true)

Without `notify`, the server never learns the new value. If this listbox
re-renders (its `value` or slot content changes), LiveView restores the
server's value. Re-renders elsewhere on the page don't affect it.

Disabled options can be set, since `disabled` only blocks user picks. A value
that matches no option, including `""`, is ignored.

From plain JavaScript, dispatch the underlying event on the listbox element:

document.getElementById("fruit-listbox").dispatchEvent(
new CustomEvent("prima:listbox:set-value", { detail: { value: "apple" } })
)
"""

use Phoenix.Component
Expand Down Expand Up @@ -248,4 +277,49 @@ defmodule Prima.Listbox do
</div>
"""
end

defmodule JS do
@moduledoc """
JavaScript commands for client-side listbox control.
"""
alias Phoenix.LiveView.JS

@doc """
Sets the value of the listbox with the given ID.

The value must match one of the listbox's options; otherwise nothing changes.
Dispatches no events unless `notify: true` is given.

## Options

* `:notify` - also dispatch `input` and `change` on the hidden input, so a
parent form's `phx-change` fires (default: `false`)

## Examples

<button type="button" phx-click={Prima.Listbox.JS.set_value("role-listbox", "viewer")}>
Reset role
</button>

<button
type="button"
phx-click={JS.push("reset") |> Prima.Listbox.JS.set_value("role-listbox", "viewer", notify: true)}
>
Reset role
</button>
"""
def set_value(id, value), do: set_value(%JS{}, id, value, [])

def set_value(%JS{} = js, id, value), do: set_value(js, id, value, [])
def set_value(id, value, opts) when is_list(opts), do: set_value(%JS{}, id, value, opts)

def set_value(%JS{} = js, id, value, opts) when is_list(opts) do
opts = Keyword.validate!(opts, notify: false)

JS.dispatch(js, "prima:listbox:set-value",
to: "##{id}",
detail: %{value: value, notify: opts[:notify]}
)
end
end
end
Loading