Skip to content
Closed
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
5 changes: 5 additions & 0 deletions assets/js/hooks/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,14 @@ export default {
},

handleToggle() {
if (this.isButtonDisabled()) return
this.toggleListbox()
},

isButtonDisabled() {
return this.refs.button.getAttribute('aria-disabled') === 'true'
},

handleClickOutside(e) {
if (this.isListboxVisible() && !this.refs.button.contains(e.target) && !this.refs.listbox.contains(e.target)) {
this.hideListbox()
Expand Down
15 changes: 15 additions & 0 deletions demo/lib/demo_web/live/demo_live/listbox_page.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@
<.code_example file="listbox/disabled.html.heex" id="disabled-listbox-demo" />
</div>

<h2>Disabled Trigger</h2>
<p>
Disable the entire listbox with <code>disabled={true}</code>
on <code>.listbox_trigger</code>. A disabled trigger can't be clicked to open the
listbox and is removed from the tab order, the same way a native
<code>&lt;select disabled&gt;</code>
behaves. Prima only sets the <code>data-disabled</code>
attribute — styling the disabled state (e.g. reduced opacity, a <code>not-allowed</code>
cursor) is entirely up to you, as shown below.
</p>

<div class="not-prose">
<.code_example file="listbox/disabled_trigger.html.heex" id="disabled-trigger-listbox-demo" />
</div>

<h2>Keyboard Interaction</h2>
<p>
The listbox provides keyboard navigation that follows standard ARIA patterns.
Expand Down
15 changes: 15 additions & 0 deletions demo/lib/demo_web/live/fixtures_live/listbox_fixture.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,19 @@
<div id="outside-area" style="height: 50px; width: 100%;">
<!-- Invisible area for click-outside testing -->
</div>

<.listbox id="disabled-listbox" name="disabled_fruit_choice">
<.listbox_trigger id="disabled-listbox-trigger" disabled={true}>
<.listbox_value>Select a fruit...</.listbox_value>
</.listbox_trigger>

<.listbox_options id="disabled-listbox-options">
<.listbox_option id="disabled-listbox-option-apple" value="apple" display="Apple">
Apple
</.listbox_option>
<.listbox_option id="disabled-listbox-option-banana" value="banana" display="Banana">
Banana
</.listbox_option>
</.listbox_options>
</.listbox>
</div>
41 changes: 41 additions & 0 deletions demo/priv/code_examples/listbox/disabled_trigger.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<.listbox id="disabled-trigger-demo-listbox" name="plan">
<.listbox_trigger
id="disabled-trigger-demo-listbox-trigger"
disabled={true}
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 data-disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:hover:bg-white"
>
<.listbox_value>Select a plan...</.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="disabled-trigger-demo-listbox-options"
class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
id="disabled-trigger-demo-option-basic"
value="Basic"
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"
>
Basic
</.listbox_option>
<.listbox_option
id="disabled-trigger-demo-option-pro"
value="Pro"
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"
>
Pro
</.listbox_option>
</.listbox_options>
</.listbox>
24 changes: 24 additions & 0 deletions demo/test/wallaby/demo_web/listbox_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,28 @@ defmodule DemoWeb.ListboxTest do
|> click(Query.css("#listbox-option-apple"))
|> assert_has(@listbox_value |> Query.text("Apple"))
end

describe "disabled trigger" do
@disabled_trigger Query.css("#disabled-listbox-trigger")
@disabled_listbox Query.css("#disabled-listbox [role=listbox]")

feature "marks the trigger as disabled and removes it from the tab order", %{
session: session
} do
session
|> visit_fixture("/fixtures/listbox", "#disabled-listbox")
|> assert_has(
Query.css(
"#disabled-listbox-trigger[aria-disabled=true][data-disabled=true][tabindex='-1']"
)
)
end

feature "does not open the listbox when clicked", %{session: session} do
session
|> visit_fixture("/fixtures/listbox", "#disabled-listbox")
|> click(@disabled_trigger)
|> assert_has(@disabled_listbox |> Query.visible(false))
end
end
end
29 changes: 21 additions & 8 deletions lib/prima/listbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ defmodule Prima.Listbox do

attr :id, :string, required: true
attr :class, :string, default: ""
attr :disabled, :boolean, default: false
attr :rest, :global
slot :inner_block, required: true

Expand All @@ -84,17 +85,26 @@ defmodule Prima.Listbox do

## Accessible Naming

The listbox is named after this trigger's accessible name (via
`aria-labelledby`). If the trigger only ever shows the *current value* (e.g.
a role picker whose trigger just says "Viewer", with no "Role" label
anywhere), the listbox gets announced by its value instead of its
purpose — the same problem as a native `<select>` with no `<label>`.

Fix it by adding an `aria-label` describing the field:
Labelling is up to the caller, as with a native `<select>`. The trigger's
content is its accessible name (via `aria-labelledby`). If the trigger only
shows the current value, put a visually hidden label next to it so that both
the meaning and the value are announced:

<.listbox_trigger id="role-listbox-trigger" aria-label="Role">
<.listbox_trigger id="role-listbox-trigger">
<span class="sr-only">Role:</span>
<.listbox_value>{@selected_role}</.listbox_value>
</.listbox_trigger>

## Disabling the Trigger

Pass `disabled={true}` to prevent the trigger from opening the listbox and
remove it from the tab order, the same way a native `<select disabled>`
behaves. Styling for the disabled state is left to the consumer — target
the `data-disabled` attribute set on the trigger:

<.listbox_trigger id="fruit-listbox-trigger" disabled={true} class="data-disabled:opacity-50">
<.listbox_value>{@selected_fruit || "Select a fruit..."}</.listbox_value>
</.listbox_trigger>
"""
def listbox_trigger(assigns) do
~H"""
Expand All @@ -104,6 +114,9 @@ defmodule Prima.Listbox do
class={@class}
aria-haspopup="listbox"
aria-expanded="false"
aria-disabled={if @disabled, do: "true"}
data-disabled={if @disabled, do: "true"}
tabindex={if @disabled, do: "-1"}
{@rest}
>
{render_slot(@inner_block)}
Expand Down
Loading