Skip to content

Commit ddbddaf

Browse files
authored
Merge pull request #34 from plausible/native-disabled
Listbox disabled state (native)
2 parents 52a2641 + 4d9caba commit ddbddaf

11 files changed

Lines changed: 442 additions & 24 deletions

File tree

‎assets/js/hooks/listbox.js‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,14 @@ const SELECTORS = {
2727
export default {
2828
mounted() {
2929
this.initialize()
30-
this.syncSelectionFromInput()
3130
},
3231

3332
updated() {
3433
this.initialize()
35-
this.syncSelectionFromInput()
3634
},
3735

3836
reconnected() {
3937
this.initialize()
40-
this.syncSelectionFromInput()
4138
},
4239

4340
destroyed() {
@@ -48,6 +45,8 @@ export default {
4845
this.cleanup()
4946
this.setupElements()
5047
this.setupEventListeners()
48+
this.syncDisabledState()
49+
this.syncSelectionFromInput()
5150
this.el.setAttribute('data-prima-ready', 'true')
5251
},
5352

@@ -75,9 +74,15 @@ export default {
7574
this.syncSelectedState(option)
7675
},
7776

77+
syncDisabledState() {
78+
const disabled = this.el.getAttribute('data-disabled') === 'true'
79+
this.refs.button.disabled = disabled
80+
if (disabled && this.isListboxVisible()) this.hideListbox()
81+
},
82+
7883
setupEventListeners() {
7984
this.listeners = [
80-
[this.refs.button, 'click', this.handleToggle.bind(this)],
85+
[this.refs.button, 'click', this.toggleListbox.bind(this)],
8186
[document, 'click', this.handleClickOutside.bind(this)],
8287
[this.refs.listbox, 'mouseover', this.handleMouseOver.bind(this)],
8388
[this.refs.listbox, 'click', this.handleListboxClick.bind(this)],
@@ -110,6 +115,8 @@ export default {
110115
},
111116

112117
handleKeydown(e) {
118+
if (this.refs.button.disabled) return
119+
113120
const keyHandlers = {
114121
[KEYS.ARROW_UP]: () => this.navigateUp(e),
115122
[KEYS.ARROW_DOWN]: () => this.navigateDown(e),
@@ -231,10 +238,6 @@ export default {
231238
this.setFocus(matchingOptions[nextIndex])
232239
},
233240

234-
handleToggle() {
235-
this.toggleListbox()
236-
},
237-
238241
handleClickOutside(e) {
239242
if (this.isListboxVisible() && !this.refs.button.contains(e.target) && !this.refs.listbox.contains(e.target)) {
240243
this.hideListbox()
@@ -249,6 +252,8 @@ export default {
249252
},
250253

251254
handleListboxClick(e) {
255+
if (this.refs.button.disabled) return
256+
252257
const option = e.target.closest(SELECTORS.OPTION)
253258
if (option && option.getAttribute('aria-disabled') !== 'true') {
254259
this.selectOption(option)
@@ -271,9 +276,7 @@ export default {
271276
this.refs.value.textContent = option.getAttribute('data-display')
272277
},
273278

274-
// Mount-time sync only: the displayed value is rendered by the caller and is
275-
// already correct on first paint, so only the ARIA/visual selection markers
276-
// are synced here - the value itself is left untouched.
279+
// Sync selection markers without changing the caller-rendered displayed value.
277280
syncSelectedState(option) {
278281
this.el.querySelector(SELECTORS.SELECTED_OPTION)?.removeAttribute('aria-selected')
279282
this.el.querySelectorAll('[data-selected]').forEach(el => el.removeAttribute('data-selected'))
@@ -349,6 +352,8 @@ export default {
349352
},
350353

351354
showListboxAndFocus(optionToFocus) {
355+
if (this.refs.button.disabled) return
356+
352357
// Wrapper pattern: Show wrapper first (display:block) so Floating UI can measure it,
353358
// then position it, then trigger inner listbox transition. This prevents the listbox
354359
// from briefly appearing at wrong position before jumping to correct position.

‎demo/lib/demo_web/components/code_example.ex‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ defmodule DemoWeb.CodeExample do
1212
DemoWeb.DemoLive.AsyncModalDemo,
1313
DemoWeb.DemoLive.FormModalDemo,
1414
DemoWeb.DemoLive.AsyncComboboxDemo,
15-
DemoWeb.DemoLive.ListboxFormDemo
15+
DemoWeb.DemoLive.ListboxFormDemo,
16+
DemoWeb.DemoLive.DisabledListboxDemo
1617
]
1718

1819
for module <- @live_component_modules, do: Code.ensure_compiled(module)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
defmodule DemoWeb.DemoLive.DisabledListboxDemo do
2+
@moduledoc false
3+
use DemoWeb, :live_component
4+
import Prima.Listbox
5+
6+
@impl true
7+
def mount(socket) do
8+
{:ok, assign(socket, disabled?: true, plan: nil, submitted: nil)}
9+
end
10+
11+
@impl true
12+
def render(assigns) do
13+
~H"""
14+
<form phx-change="plan_changed" phx-submit="submit" phx-target={@myself}>
15+
<div class="flex items-center gap-4">
16+
<.listbox id="disabled-listbox-example" name="plan" value={@plan} disabled={@disabled?}>
17+
<.listbox_trigger
18+
id="disabled-listbox-example-trigger"
19+
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 in-data-disabled:opacity-50 in-data-disabled:cursor-not-allowed in-data-disabled:hover:bg-white"
20+
>
21+
<.listbox_value>{@plan || "Select a plan..."}</.listbox_value>
22+
<svg
23+
class="h-5 w-5 text-gray-400"
24+
viewBox="0 0 20 20"
25+
fill="currentColor"
26+
aria-hidden="true"
27+
>
28+
<path
29+
fill-rule="evenodd"
30+
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"
31+
clip-rule="evenodd"
32+
/>
33+
</svg>
34+
</.listbox_trigger>
35+
36+
<.listbox_options
37+
id="disabled-listbox-example-options"
38+
class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
39+
>
40+
<.listbox_option
41+
id="disabled-listbox-example-option-basic"
42+
value="Basic"
43+
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
44+
>
45+
Basic
46+
</.listbox_option>
47+
<.listbox_option
48+
id="disabled-listbox-example-option-pro"
49+
value="Pro"
50+
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
51+
>
52+
Pro
53+
</.listbox_option>
54+
</.listbox_options>
55+
</.listbox>
56+
57+
<button
58+
id="disabled-listbox-example-toggle"
59+
type="button"
60+
phx-click="toggle_disabled"
61+
phx-target={@myself}
62+
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
63+
>
64+
{if @disabled?, do: "Enable", else: "Disable"}
65+
</button>
66+
67+
<button
68+
id="disabled-listbox-example-submit"
69+
type="submit"
70+
class="rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700"
71+
>
72+
Submit
73+
</button>
74+
</div>
75+
76+
<p class="mt-4 text-sm text-gray-600">
77+
Submitted params:
78+
<span id="disabled-listbox-example-submitted" class="font-mono text-gray-900">
79+
{if @submitted, do: inspect(@submitted), else: "not submitted yet"}
80+
</span>
81+
</p>
82+
</form>
83+
"""
84+
end
85+
86+
@impl true
87+
def handle_event("plan_changed", %{"plan" => plan}, socket) do
88+
{:noreply, assign(socket, plan: plan)}
89+
end
90+
91+
def handle_event("toggle_disabled", _params, socket) do
92+
{:noreply, update(socket, :disabled?, &(!&1))}
93+
end
94+
95+
def handle_event("submit", params, socket) do
96+
{:noreply, assign(socket, submitted: params)}
97+
end
98+
end

‎demo/lib/demo_web/live/demo_live/listbox_page.html.heex‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@
7676
<.code_example file="listbox/disabled.html.heex" id="disabled-listbox-demo" />
7777
</div>
7878

79+
<p>
80+
Disabled items use <code>aria-disabled="true"</code>
81+
and <code>data-disabled="true"</code>
82+
attributes for accessibility and styling. Use the <code>data-disabled:</code>
83+
variant to style disabled items differently, such as reducing opacity or changing text color.
84+
</p>
85+
86+
<h2>Disabled Listbox</h2>
87+
<p>
88+
Disable the entire listbox with <code>disabled={true}</code>
89+
on <code>.listbox</code>. The hidden input is disabled in the initial HTML,
90+
excluding its value from form submission. When the JavaScript hook mounts, it
91+
disables the trigger button, preventing interaction and removing it from the tab
92+
order. LiveView updates keep the state synchronized and close an open listbox
93+
when it becomes disabled. Re-enabling the field preserves its selected value.
94+
</p>
95+
96+
<div class="not-prose">
97+
<.code_example
98+
file="listbox/disabled_listbox_demo.ex"
99+
module={DemoWeb.DemoLive.DisabledListboxDemo}
100+
id="disabled-listbox-code-example"
101+
/>
102+
</div>
103+
104+
<p>
105+
To style the disabled state, use the <code>in-data-disabled:</code> variant on the
106+
trigger button. The regular <code>data-disabled:</code> does not work because Prima
107+
is unable to copy the <code>data-disabled="true"</code> attribute over to the
108+
trigger button in the server-rendered HTML.
109+
</p>
110+
79111
<h2>Keyboard Interaction</h2>
80112
<p>
81113
The listbox provides keyboard navigation that follows standard ARIA patterns.

‎demo/lib/demo_web/live/fixtures_live.ex‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defmodule DemoWeb.FixturesLive do
1919
|> assign(async_modal_open?: false)
2020
|> assign(selected_fruit: nil)
2121
|> assign(form_change_count: 0)
22+
|> assign(listbox_disabled?: false, submitted_fruit: "not submitted")
2223
|> assign(trigger_label: "Open Dropdown")
2324
|> assign(modal_title: "Good news")
2425
|> stream_configure(:suggestions, dom_id: &"suggestions-#{&1}")
@@ -73,6 +74,16 @@ defmodule DemoWeb.FixturesLive do
7374
{:noreply, socket}
7475
end
7576

77+
@impl true
78+
def handle_event("toggle-listbox-disabled", _params, socket) do
79+
{:noreply, update(socket, :listbox_disabled?, &(!&1))}
80+
end
81+
82+
@impl true
83+
def handle_event("listbox_form_submitted", params, socket) do
84+
{:noreply, assign(socket, submitted_fruit: Map.get(params, "fruit", "omitted"))}
85+
end
86+
7687
@impl true
7788
def handle_event("update-dropdown-trigger", _params, socket) do
7889
{:noreply, assign(socket, trigger_label: "Updated Trigger")}

‎demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,21 @@
3838
<div id="outside-area" style="height: 50px; width: 100%;">
3939
<!-- Invisible area for click-outside testing -->
4040
</div>
41+
42+
<button id="before-disabled-listbox" type="button">Before disabled listbox</button>
43+
<.listbox id="disabled-listbox" name="disabled_fruit_choice" disabled={true}>
44+
<.listbox_trigger id="disabled-listbox-trigger">
45+
<.listbox_value>Select a fruit...</.listbox_value>
46+
</.listbox_trigger>
47+
48+
<.listbox_options id="disabled-listbox-options">
49+
<.listbox_option id="disabled-listbox-option-apple" value="apple" display="Apple">
50+
Apple
51+
</.listbox_option>
52+
<.listbox_option id="disabled-listbox-option-banana" value="banana" display="Banana">
53+
Banana
54+
</.listbox_option>
55+
</.listbox_options>
56+
</.listbox>
57+
<button id="after-disabled-listbox" type="button">After disabled listbox</button>
4158
</div>

‎demo/lib/demo_web/live/fixtures_live/listbox_form_fixture.html.heex‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
<form phx-change="form_changed">
2-
<.listbox id="listbox-form" name="fruit" value={@selected_fruit}>
1+
<form id="listbox-test-form" phx-change="form_changed" phx-submit="listbox_form_submitted">
2+
<.listbox id="listbox-form" name="fruit" value={@selected_fruit} disabled={@listbox_disabled?}>
33
<.listbox_trigger
44
id="listbox-form-trigger"
55
class="w-64 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700"
@@ -26,4 +26,13 @@
2626
<div id="listbox-form-change-count" data-testid="change-count">
2727
Form changes: {@form_change_count}
2828
</div>
29+
<button id="submit-listbox-form" type="submit">Submit</button>
30+
<div id="listbox-submission">Submitted: {@submitted_fruit}</div>
2931
</form>
32+
<button
33+
id="toggle-listbox-disabled"
34+
type="button"
35+
phx-click={Phoenix.LiveView.JS.push("toggle-listbox-disabled")}
36+
>
37+
Toggle disabled
38+
</button>

0 commit comments

Comments
 (0)