Skip to content

Commit 87816dc

Browse files
committed
select: more tests, some bug fixes and disabled example
1 parent 94a3257 commit 87816dc

File tree

10 files changed

+213
-29
lines changed

10 files changed

+213
-29
lines changed

playwright/select.spec.ts

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ test("test", async ({ page }) => {
55
timeout: 20 * 60 * 1000,
66
}); // Increase timeout to 20 minutes
77
// Find Select a fruit...
8-
let selectTrigger = page.locator(".select-trigger");
8+
let selectTrigger = page.locator("#select-main .select-trigger");
99
await selectTrigger.click();
1010
// Assert the select menu is open
11-
const selectMenu = page.locator(".select-list");
11+
const selectMenu = page.locator("#select-main .select-list");
1212
await expect(selectMenu).toHaveAttribute("data-state", "open");
1313

1414
// Assert the menu is focused
@@ -64,10 +64,10 @@ test("test", async ({ page }) => {
6464
test("tabbing out of menu closes the select menu", async ({ page }) => {
6565
await page.goto("http://127.0.0.1:8080/component/?name=select&");
6666
// Find Select a fruit...
67-
let selectTrigger = page.locator(".select-trigger");
67+
let selectTrigger = page.locator("#select-main .select-trigger");
6868
await selectTrigger.click();
6969
// Assert the select menu is open
70-
const selectMenu = page.locator(".select-list");
70+
const selectMenu = page.locator("#select-main .select-list");
7171
await expect(selectMenu).toHaveAttribute("data-state", "open");
7272

7373
// Assert the menu is focused
@@ -80,10 +80,10 @@ test("tabbing out of menu closes the select menu", async ({ page }) => {
8080
test("tabbing out of item closes the select menu", async ({ page }) => {
8181
await page.goto("http://127.0.0.1:8080/component/?name=select&");
8282
// Find Select a fruit...
83-
let selectTrigger = page.locator(".select-trigger");
83+
let selectTrigger = page.locator("#select-main .select-trigger");
8484
await selectTrigger.click();
8585
// Assert the select menu is open
86-
const selectMenu = page.locator(".select-list");
86+
const selectMenu = page.locator("#select-main .select-list");
8787
await expect(selectMenu).toHaveAttribute("data-state", "open");
8888

8989
// Assert the menu is focused
@@ -101,10 +101,10 @@ test("tabbing out of item closes the select menu", async ({ page }) => {
101101
test("options selected", async ({ page }) => {
102102
await page.goto("http://127.0.0.1:8080/component/?name=select&");
103103
// Find Select a fruit...
104-
let selectTrigger = page.locator(".select-trigger");
104+
let selectTrigger = page.locator("#select-main .select-trigger");
105105
await selectTrigger.click();
106106
// Assert the select menu is open
107-
const selectMenu = page.locator(".select-list");
107+
const selectMenu = page.locator("#select-main .select-list");
108108
await expect(selectMenu).toHaveAttribute("data-state", "open");
109109

110110
// Assert no items have aria-selected
@@ -130,25 +130,126 @@ test("options selected", async ({ page }) => {
130130
test("down arrow selects first element", async ({ page }) => {
131131
await page.goto("http://127.0.0.1:8080/component/?name=select&");
132132
// Find Select a fruit...
133-
let selectTrigger = page.locator(".select-trigger");
134-
const selectMenu = page.locator(".select-list");
133+
let selectTrigger = page.locator("#select-main .select-trigger");
134+
const selectMenu = page.locator("#select-main .select-list");
135135
await selectTrigger.focus();
136136

137137
// Select the first option
138138
await page.keyboard.press("ArrowDown");
139139
const firstOption = selectMenu.getByRole("option", { name: "apple" });
140140
await expect(firstOption).toBeFocused();
141+
142+
// Same thing but with the first option disabled
143+
let disabledSelectTrigger = page.locator("#select-disabled .select-trigger");
144+
const disabledSelectMenu = page.locator("#select-disabled .select-list");
145+
await disabledSelectTrigger.focus();
146+
await page.keyboard.press("ArrowDown");
147+
const disabledFirstOption = disabledSelectMenu.getByRole("option", { name: "banana" });
148+
await expect(disabledFirstOption).toBeFocused();
141149
});
142150

143151
test("up arrow selects last element", async ({ page }) => {
144152
await page.goto("http://127.0.0.1:8080/component/?name=select&");
145153
// Find Select a fruit...
146-
let selectTrigger = page.locator(".select-trigger");
147-
const selectMenu = page.locator(".select-list");
154+
let selectTrigger = page.locator("#select-main .select-trigger");
155+
const selectMenu = page.locator("#select-main .select-list");
148156
await selectTrigger.focus();
149157

150158
// Select the first option
151159
await page.keyboard.press("ArrowUp");
152-
const firstOption = selectMenu.getByRole("option", { name: "other" });
160+
const lastOption = selectMenu.getByRole("option", { name: "other" });
161+
await expect(lastOption).toBeFocused();
162+
163+
// Same thing but with the last option disabled
164+
let disabledSelectTrigger = page.locator("#select-disabled .select-trigger");
165+
const disabledSelectMenu = page.locator("#select-disabled .select-list");
166+
await disabledSelectTrigger.focus();
167+
168+
await page.keyboard.press("ArrowUp");
169+
const disabledLastOption = disabledSelectMenu.getByRole("option", { name: "watermelon" });
170+
await expect(disabledLastOption).toBeFocused();
171+
});
172+
173+
test("rollover on top and bottom", async ({ page }) => {
174+
await page.goto("http://127.0.0.1:8080/component/?name=select&");
175+
176+
// Find Select a fruit...
177+
let selectTrigger = page.locator("#select-main .select-trigger");
178+
const selectMenu = page.locator("#select-main .select-list");
179+
await selectTrigger.focus();
180+
181+
// open the list and select first option
182+
await page.keyboard.press("ArrowDown");
183+
const firstOption = selectMenu.getByRole("option", { name: "apple" });
153184
await expect(firstOption).toBeFocused();
185+
186+
// up arrow to select last option (rollover)
187+
await page.keyboard.press("ArrowUp");
188+
const lastOption = selectMenu.getByRole("option", { name: "other" });
189+
await expect(lastOption).toBeFocused();
190+
191+
// down arrow to select first option (rollover)
192+
await page.keyboard.press("ArrowDown");
193+
await expect(firstOption).toBeFocused();
194+
195+
// Same thing but with first and last options disabled
196+
let disabledSelectTrigger = page.locator("#select-disabled .select-trigger");
197+
const disabledSelectMenu = page.locator("#select-disabled .select-list");
198+
await disabledSelectTrigger.focus();
199+
200+
// open the list and select first option
201+
await page.keyboard.press("ArrowDown");
202+
const disabledFirstOption = disabledSelectMenu.getByRole("option", { name: "banana" });
203+
await expect(disabledFirstOption).toBeFocused();
204+
205+
// up arrow to select last option (rollover)
206+
await page.keyboard.press("ArrowUp");
207+
const disabledLastOption = disabledSelectMenu.getByRole("option", { name: "watermelon" });
208+
await expect(disabledLastOption).toBeFocused();
209+
210+
// down arrow to select first option (rollover)
211+
await page.keyboard.press("ArrowDown");
212+
await expect(disabledFirstOption).toBeFocused();
213+
});
214+
215+
test("disabled elements are skipped", async ({ page }) => {
216+
await page.goto("http://127.0.0.1:8080/component/?name=select&");
217+
218+
// Find Select a fruit...
219+
let selectTrigger = page.locator("#select-disabled .select-trigger");
220+
const selectMenu = page.locator("#select-disabled .select-list");
221+
await selectTrigger.focus();
222+
223+
// open the list and select first enabled option
224+
await page.keyboard.press("ArrowDown");
225+
const firstOption = selectMenu.getByRole("option", { name: "banana" });
226+
await expect(firstOption).toBeFocused();
227+
228+
// down arrow to select second enabled option
229+
await page.keyboard.press("ArrowDown");
230+
const secondOption = selectMenu.getByRole("option", { name: "strawberry" });
231+
await expect(secondOption).toBeFocused();
232+
233+
// up arrow to select first enabled option
234+
await page.keyboard.press("ArrowUp");
235+
await expect(firstOption).toBeFocused();
236+
});
237+
238+
test("aria active descendant", async ({ page }) => {
239+
await page.goto("http://127.0.0.1:8080/component/?name=select&");
240+
241+
// Find Select a fruit...
242+
let selectTrigger = page.locator("#select-main .select-trigger");
243+
const selectMenu = page.locator("#select-main .select-list");
244+
await selectTrigger.focus();
245+
246+
// select first option
247+
await page.keyboard.press("ArrowDown");
248+
const firstOption = selectMenu.getByRole("option", { name: "apple" });
249+
await expect(selectTrigger).toHaveAttribute("aria-activedescendant", await firstOption.getAttribute("id"));
250+
251+
// select second option
252+
await page.keyboard.press("ArrowDown");
253+
const secondOption = selectMenu.getByRole("option", { name: "banana" });
254+
await expect(selectTrigger).toHaveAttribute("aria-activedescendant", await secondOption.getAttribute("id"));
154255
});

preview/src/components/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ examples!(
7979
progress,
8080
radio_group,
8181
scroll_area,
82-
select,
82+
select[disabled],
8383
separator,
8484
skeleton,
8585
sheet,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use super::super::component::*;
2+
use dioxus::prelude::*;
3+
use strum::{EnumCount, IntoEnumIterator};
4+
5+
#[derive(Debug, Clone, Copy, PartialEq, strum::EnumCount, strum::EnumIter, strum::Display)]
6+
enum Fruit {
7+
Apple,
8+
Banana,
9+
Orange,
10+
Strawberry,
11+
Watermelon,
12+
}
13+
14+
impl Fruit {
15+
const fn emoji(&self) -> &'static str {
16+
match self {
17+
Fruit::Apple => "🍎",
18+
Fruit::Banana => "🍌",
19+
Fruit::Orange => "🍊",
20+
Fruit::Strawberry => "🍓",
21+
Fruit::Watermelon => "🍉",
22+
}
23+
}
24+
25+
const fn disabled(&self) -> bool {
26+
match self {
27+
Fruit::Apple => true,
28+
Fruit::Orange => true,
29+
_ => false
30+
}
31+
}
32+
}
33+
34+
#[component]
35+
pub fn Demo() -> Element {
36+
let fruits = Fruit::iter().enumerate().map(|(i, f)| {
37+
rsx! {
38+
SelectOption::<Option<Fruit>> {
39+
index: i,
40+
value: f,
41+
text_value: "{f}",
42+
disabled: f.disabled(),
43+
{format!("{} {f}", f.emoji())}
44+
SelectItemIndicator {}
45+
}
46+
}
47+
});
48+
49+
rsx! {
50+
Select::<Option<Fruit>> { id: "select-disabled", placeholder: "Select a fruit...",
51+
SelectTrigger { aria_label: "Select Trigger", width: "12rem", SelectValue {} }
52+
SelectList { aria_label: "Select Demo",
53+
SelectGroup {
54+
SelectGroupLabel { "Fruits" }
55+
{fruits}
56+
}
57+
SelectGroup {
58+
SelectGroupLabel { "Other" }
59+
SelectOption::<Option<Fruit>> {
60+
index: Fruit::COUNT,
61+
value: None,
62+
text_value: "Other",
63+
disabled: true,
64+
"Other"
65+
SelectItemIndicator {}
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}

preview/src/components/select/variants/main/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub fn Demo() -> Element {
3636

3737
rsx! {
3838

39-
Select::<Option<Fruit>> { placeholder: "Select a fruit...",
39+
Select::<Option<Fruit>> { id: "select-main", placeholder: "Select a fruit...",
4040
SelectTrigger { aria_label: "Select Trigger", width: "12rem", SelectValue {} }
4141
SelectList { aria_label: "Select Demo",
4242
SelectGroup {

primitives/src/select/components/group.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element {
173173
let render = use_context::<SelectListContext>().render;
174174

175175
rsx! {
176-
if render () {
176+
if render() {
177177
div {
178178
// Set the ID for the label
179179
id,

primitives/src/select/components/list.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,12 @@ pub fn SelectList(props: SelectListProps) -> Element {
163163

164164
use_effect(move || {
165165
if render() {
166-
if (ctx.initial_focus_last)().unwrap_or_default() {
167-
ctx.focus_last();
168-
} else {
169-
ctx.focus_first();
166+
if let Some(last) = (ctx.initial_focus_last)() {
167+
if last {
168+
ctx.focus_last();
169+
} else {
170+
ctx.focus_first();
171+
}
170172
}
171173
} else {
172174
ctx.initial_focus_last.set(None);

primitives/src/select/components/option.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ pub fn SelectOption<T: PartialEq + Clone + 'static>(props: SelectOptionProps<T>)
194194
"data-disabled": disabled,
195195

196196
onpointerdown: move |event| {
197-
if !disabled && event.trigger_button() == Some(MouseButton::Primary) {
197+
if event.trigger_button() == Some(MouseButton::Primary) {
198+
if disabled {
199+
event.prevent_default();
200+
event.stop_propagation();
201+
return;
202+
}
198203
ctx.set_value.call(Some(RcPartialEqValue::new(props.value.cloned())));
199204
ctx.open.set(false);
200205
}

primitives/src/select/components/trigger.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element {
6969
let mut ctx = use_context::<SelectContext>();
7070
let mut open = ctx.open;
7171

72+
let focus_id = use_memo(move || ctx.current_focus_id());
73+
7274
rsx! {
7375
button {
7476
// Standard HTML attributes
7577
disabled: (ctx.disabled)(),
76-
type: "button",
78+
r#type: "button",
7779

7880
onclick: move |_| {
7981
open.toggle();
@@ -97,9 +99,11 @@ pub fn SelectTrigger(props: SelectTriggerProps) -> Element {
9799
},
98100

99101
// ARIA attributes
102+
role: "combobox",
100103
aria_haspopup: "listbox",
101104
aria_expanded: open(),
102105
aria_controls: ctx.list_id,
106+
aria_activedescendant: focus_id,
103107

104108
// Pass through other attributes
105109
..props.attributes,

primitives/src/select/components/value.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,6 @@ pub fn SelectValue(props: SelectValueProps) -> Element {
8080

8181
rsx! {
8282
// Add placeholder option if needed
83-
span {
84-
"data-placeholder": ctx.value.read().is_none(),
85-
..props.attributes,
86-
{display_value}
87-
}
83+
span { "data-placeholder": ctx.value.read().is_none(), ..props.attributes, {display_value} }
8884
}
8985
}

primitives/src/select/context.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ pub(super) struct SelectContext {
6969
/// Timeout before clearing typeahead buffer
7070
pub typeahead_timeout: ReadSignal<Duration>,
7171
/// A list of options with their states
72-
pub options: Signal<BTreeMap<usize, OptionState>>,
72+
pub(crate) options: Signal<BTreeMap<usize, OptionState>>,
7373
/// If focus should loop around
7474
pub roving_loop: ReadSignal<bool>,
7575
/// The currently selected option tab_index
76-
pub current_focus: Signal<Option<usize>>,
76+
pub(crate) current_focus: Signal<Option<usize>>,
7777
/// The initial element to focus once the list is rendered<br>
7878
/// true: last element<br>
7979
/// false: first element
@@ -94,6 +94,11 @@ impl SelectContext {
9494
(self.current_focus)()
9595
}
9696

97+
pub(crate) fn current_focus_id(&self) -> Option<String> {
98+
let focus = (self.current_focus)()?;
99+
self.options.read().get(&focus).map(|s| s.id.clone())
100+
}
101+
97102
pub(crate) fn blur(&mut self) {
98103
self.current_focus.write().take();
99104
}

0 commit comments

Comments
 (0)