Skip to content

Commit 4577c87

Browse files
committed
feat(cfasim-ui): add autocomplete mode to SelectBox
1 parent e72132d commit 4577c87

4 files changed

Lines changed: 363 additions & 2 deletions

File tree

‎cfasim-ui/components/src/SelectBox/SelectBox.md‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
---
2+
keywords: [select, dropdown, autocomplete, combobox, filter, search, typeahead]
3+
---
4+
15
# SelectBox
26

3-
A dropdown select built on reka-ui.
7+
A single-select dropdown built on reka-ui. Set `autocomplete` to turn it into a
8+
filterable single-select combobox: type to narrow the options, just like
9+
[MultiSelect](./multi-select) but bound to a single `string`.
410

511
## Examples
612

713
<script setup>
814
import { ref } from 'vue'
915
const interval = ref('weekly')
16+
const state = ref('')
1017
</script>
1118

1219
<ComponentDemo>
@@ -83,6 +90,73 @@ DOM.
8390
</template>
8491
</ComponentDemo>
8592

93+
### Autocomplete
94+
95+
Add `autocomplete` to make the field filterable. Type to narrow the list; the
96+
selected option's label fills the input. `v-model` is still a single `string`.
97+
98+
<ComponentDemo>
99+
<div style="width: 240px">
100+
<SelectBox
101+
v-model="state"
102+
autocomplete
103+
label="State"
104+
placeholder="Search states…"
105+
:options="[
106+
{ value: 'ca', label: 'California' },
107+
{ value: 'ny', label: 'New York' },
108+
{ value: 'tx', label: 'Texas' },
109+
{ value: 'wa', label: 'Washington' },
110+
{ value: 'fl', label: 'Florida' },
111+
]"
112+
/>
113+
</div>
114+
115+
<template #code>
116+
117+
```vue
118+
<script setup>
119+
import { ref } from "vue";
120+
const state = ref("");
121+
</script>
122+
123+
<SelectBox
124+
v-model="state"
125+
autocomplete
126+
label="State"
127+
placeholder="Search states…"
128+
:options="[
129+
{ value: 'ca', label: 'California' },
130+
{ value: 'ny', label: 'New York' },
131+
{ value: 'tx', label: 'Texas' },
132+
{ value: 'wa', label: 'Washington' },
133+
{ value: 'fl', label: 'Florida' },
134+
]"
135+
/>
136+
```
137+
138+
</template>
139+
</ComponentDemo>
140+
141+
## Accessibility
142+
143+
In the default mode the field is a reka-ui Select; with `autocomplete` it's a
144+
reka-ui Combobox following the
145+
[ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/):
146+
147+
- The input has `role="combobox"` with `aria-expanded`, `aria-controls`, and
148+
`aria-autocomplete="list"`; the popup is a `role="listbox"` and each option a
149+
`role="option"` that reflects its state via `aria-selected`.
150+
- Keyboard support is handled for you: type to filter, ↑/↓ to move through the
151+
list, Enter to select the highlighted option, and Escape to close.
152+
- Pass `label` so the field is named by a real `<label>`. In autocomplete mode
153+
the label is also wired to the input via `for`/`id`, so clicking it focuses
154+
the field; use `hide-label` to keep the label for screen readers while hiding
155+
it visually, or `aria-label` when there's no visible label text.
156+
- The chevron is a labelled toggle (`aria-label="Toggle options"`) kept out of
157+
the tab order, and native browser autofill is disabled so it can't cover the
158+
option list.
159+
86160
<!--@include: ./_api/select-box.md-->
87161

88162
### SelectOption

‎cfasim-ui/components/src/SelectBox/SelectBox.spec.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,46 @@ test("SelectBox page renders demos", async ({ page }) => {
77
await expect(demos.first()).toBeVisible();
88
await expect(demos.first().getByText("Interval")).toBeVisible();
99
});
10+
11+
test("SelectBox autocomplete filters and selects an option", async ({
12+
page,
13+
}) => {
14+
await page.goto("./cfasim-ui/components/select-box");
15+
const demo = page.locator(".demo-preview", {
16+
has: page.locator(".select-input"),
17+
});
18+
const input = demo.locator(".select-input").first();
19+
20+
await input.click();
21+
await input.fill("tex");
22+
await expect(page.getByRole("option", { name: "Texas" })).toBeVisible();
23+
await expect(page.getByRole("option", { name: "California" })).toHaveCount(0);
24+
25+
await page.getByRole("option", { name: "Texas" }).click();
26+
// The selected label fills the input and the list collapses.
27+
await expect(input).toHaveValue("Texas");
28+
await expect(page.getByRole("option")).toHaveCount(0);
29+
});
30+
31+
test("SelectBox autocomplete is operable by keyboard", async ({ page }) => {
32+
await page.goto("./cfasim-ui/components/select-box");
33+
const demo = page.locator(".demo-preview", {
34+
has: page.locator(".select-input"),
35+
});
36+
const input = demo.locator(".select-input").first();
37+
38+
// The combobox exposes the ARIA combobox pattern.
39+
await expect(input).toHaveRole("combobox");
40+
await expect(input).toHaveAttribute("aria-autocomplete", "list");
41+
42+
await input.click();
43+
await expect(input).toHaveAttribute("aria-expanded", "true");
44+
await expect(page.getByRole("listbox")).toBeVisible();
45+
46+
// Filter, then pick the highlighted option with the keyboard only.
47+
await input.fill("wash");
48+
await page.keyboard.press("ArrowDown");
49+
await page.keyboard.press("Enter");
50+
await expect(input).toHaveValue("Washington");
51+
await expect(input).toHaveAttribute("aria-expanded", "false");
52+
});

‎cfasim-ui/components/src/SelectBox/SelectBox.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,92 @@ describe("SelectBox", () => {
5454
expect(trigger.attributes("role")).toBe("combobox");
5555
expect(trigger.attributes("aria-labelledby")).toBeDefined();
5656
});
57+
58+
describe("autocomplete", () => {
59+
it("renders a combobox input instead of the select trigger", () => {
60+
const wrapper = mount(SelectBox, {
61+
props: {
62+
label: "Interval",
63+
options,
64+
modelValue: "daily",
65+
autocomplete: true,
66+
},
67+
});
68+
expect(wrapper.find(".select-trigger").exists()).toBe(false);
69+
const input = wrapper.find(".select-input");
70+
expect(input.exists()).toBe(true);
71+
expect(input.attributes("role")).toBe("combobox");
72+
});
73+
74+
it("displays the selected option's label in the input", () => {
75+
const wrapper = mount(SelectBox, {
76+
props: { options, modelValue: "weekly", autocomplete: true },
77+
});
78+
expect(
79+
(wrapper.find(".select-input").element as HTMLInputElement).value,
80+
).toBe("Weekly");
81+
});
82+
83+
it("labels the input via aria-labelledby when a label is given", () => {
84+
const wrapper = mount(SelectBox, {
85+
props: {
86+
label: "Interval",
87+
options,
88+
modelValue: "daily",
89+
autocomplete: true,
90+
},
91+
});
92+
expect(
93+
wrapper.find(".select-input").attributes("aria-labelledby"),
94+
).toBeDefined();
95+
});
96+
97+
it("associates the label with the input for click-to-focus", () => {
98+
const wrapper = mount(SelectBox, {
99+
props: {
100+
label: "Interval",
101+
options,
102+
modelValue: "daily",
103+
autocomplete: true,
104+
},
105+
});
106+
const inputId = wrapper.find(".select-input").attributes("id");
107+
expect(inputId).toBeTruthy();
108+
expect(wrapper.find(".select-label").attributes("for")).toBe(inputId);
109+
});
110+
111+
it("advertises list autocomplete and disables native autofill", () => {
112+
const wrapper = mount(SelectBox, {
113+
props: { options, modelValue: "daily", autocomplete: true },
114+
});
115+
const input = wrapper.find(".select-input");
116+
expect(input.attributes("aria-autocomplete")).toBe("list");
117+
// Suppress the browser's own autofill dropdown over reka's listbox.
118+
expect(input.attributes("autocomplete")).toBe("off");
119+
});
120+
121+
it("exposes the chevron as a labelled toggle outside the tab order", () => {
122+
const wrapper = mount(SelectBox, {
123+
props: { options, modelValue: "daily", autocomplete: true },
124+
});
125+
const toggle = wrapper.find(".select-icon-button");
126+
expect(toggle.attributes("aria-label")).toBe("Toggle options");
127+
expect(toggle.attributes("aria-haspopup")).toBe("listbox");
128+
expect(toggle.attributes("tabindex")).toBe("-1");
129+
});
130+
131+
it("falls back to ariaLabel when no label is given", () => {
132+
const wrapper = mount(SelectBox, {
133+
props: {
134+
options,
135+
modelValue: "",
136+
autocomplete: true,
137+
ariaLabel: "Pick interval",
138+
},
139+
});
140+
expect(wrapper.find(".select-input").attributes("aria-label")).toBe(
141+
"Pick interval",
142+
);
143+
});
144+
});
57145
});

0 commit comments

Comments
 (0)