Skip to content

Commit 0b3a28a

Browse files
authored
WB-1757.1: Combobox - Add error prop (#2321)
## Summary: - Added `error` prop to the `Combobox` component to handle the error state. - Included an X icon to clear the combobox selection in single-selection mode. This is useful for the user to quickly reset the value when needed. - Textbox: Modifies the order in which the `aria-invalid` attribute is applied to the input element. Issue: WB-1757 ## Test plan: 1. Navigate to the `Error` story: /?path=/story/packages-dropdown-combobox--error 2. Verify that the error state is displayed and `aria-invalid` is set `true`. 3. Open the listbox and select one option. 4. Verify that the error is gone and `aria-invalid=false`. 5. Verify that the "Clear selection" button (X) is now visible. 6. Click on the X button. 7. Verify that the error is visible again and `aria-invalid=true`. https://github.com/user-attachments/assets/22f69acd-9345-45aa-a626-ea0822a85c29 Author: jandrade Reviewers: somewhatabstract, beaesguerra, jandrade, marcysutton Required Reviewers: Approved By: somewhatabstract, beaesguerra Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 2/2), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Test (ubuntu-latest, 20.x, 1/2), ✅ Lint (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏭️ dependabot, ✅ gerald Pull Request URL: #2321
1 parent 5cefb6b commit 0b3a28a

File tree

8 files changed

+300
-19
lines changed

8 files changed

+300
-19
lines changed

.changeset/rare-islands-pretend.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@khanacademy/wonder-blocks-dropdown": minor
3+
"@khanacademy/wonder-blocks-form": patch
4+
---
5+
6+
- Combobox: Add error prop to support aria-invalid and styling changes.
7+
- TextField: Modify aria-invalid order to be overriden by the caller.

__docs__/wonder-blocks-dropdown/combobox.stories.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,35 @@ export const AutoCompleteMultiSelect: Story = {
396396
},
397397
},
398398
};
399+
400+
/**
401+
* This `Combobox` is in an error state. Selecting any option will clear the
402+
* error state by updating the `error` prop to `false`.
403+
*
404+
* **NOTE:** We internally apply the correct `aria-invalid` attribute based on
405+
* the `error` prop.
406+
*/
407+
408+
export const Error: Story = {
409+
render: function Render(args: PropsFor<typeof Combobox>) {
410+
const [error, setError] = React.useState(args.error);
411+
const [value, setValue] = React.useState(args.value);
412+
413+
return (
414+
<Combobox
415+
{...args}
416+
error={error}
417+
value={value}
418+
onChange={(newValue) => {
419+
setValue(newValue);
420+
setError(newValue !== "" ? false : true);
421+
action("onChange")(newValue);
422+
}}
423+
/>
424+
);
425+
},
426+
args: {
427+
children: items,
428+
error: true,
429+
},
430+
};

packages/wonder-blocks-dropdown/src/components/__tests__/combobox.test.tsx

+163-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {PointerEventsCheckLevel, userEvent} from "@testing-library/user-event";
66
import Combobox from "../combobox";
77
import OptionItem from "../option-item";
88
import {defaultComboboxLabels} from "../../util/constants";
9+
import {MaybeValueOrValues} from "../../util/types";
910

1011
const doRender = (element: React.ReactElement) => {
1112
render(element, {wrapper: RenderStateRoot});
@@ -58,7 +59,9 @@ describe("Combobox", () => {
5859
);
5960

6061
// Act
61-
await userEvent.click(screen.getByRole("button"));
62+
await userEvent.click(
63+
screen.getByRole("button", {name: /toggle listbox/i}),
64+
);
6265

6366
// Assert
6467
await screen.findByRole("listbox", {hidden: true});
@@ -96,11 +99,15 @@ describe("Combobox", () => {
9699
</Combobox>,
97100
);
98101

99-
await userEvent.click(screen.getByRole("button"));
102+
await userEvent.click(
103+
screen.getByRole("button", {name: /toggle listbox/i}),
104+
);
100105
await screen.findByRole("listbox", {hidden: true});
101106

102107
// Act
103-
await userEvent.click(screen.getByRole("button"));
108+
await userEvent.click(
109+
screen.getByRole("button", {name: /toggle listbox/i}),
110+
);
104111

105112
// Assert
106113
expect(
@@ -214,7 +221,9 @@ describe("Combobox", () => {
214221
</Combobox>,
215222
);
216223

217-
await userEvent.click(screen.getByRole("button"));
224+
await userEvent.click(
225+
screen.getByRole("button", {name: /toggle listbox/i}),
226+
);
218227
await screen.findByRole("listbox", {hidden: true});
219228

220229
// Act
@@ -296,6 +305,133 @@ describe("Combobox", () => {
296305
expect(screen.getByRole("combobox")).not.toHaveFocus();
297306
});
298307

308+
describe("dismiss button", () => {
309+
it("should clear the value when the user presses the clear button (x) via Mouse", async () => {
310+
// Arrange
311+
const userEvent = doRender(
312+
<Combobox selectionType="single" value="option2">
313+
<OptionItem label="option 1" value="option1" />
314+
<OptionItem label="option 2" value="option2" />
315+
<OptionItem label="option 3" value="option3" />
316+
</Combobox>,
317+
);
318+
319+
// Act
320+
await userEvent.click(
321+
screen.getByRole("button", {name: /clear selection/i}),
322+
);
323+
324+
// Assert
325+
expect(screen.getByRole("combobox")).toHaveValue("");
326+
});
327+
328+
it("should clear the value when the user presses the clear button (x) via Keyboard", async () => {
329+
// Arrange
330+
const userEvent = doRender(
331+
<Combobox selectionType="single" value="option2">
332+
<OptionItem label="option 1" value="option1" />
333+
<OptionItem label="option 2" value="option2" />
334+
<OptionItem label="option 3" value="option3" />
335+
</Combobox>,
336+
);
337+
338+
// focus the combobox
339+
await userEvent.tab();
340+
341+
// Act
342+
// Focus the clear button, then press Enter
343+
await userEvent.tab();
344+
await userEvent.keyboard("{Enter}");
345+
346+
// Assert
347+
expect(screen.getByRole("combobox")).toHaveValue("");
348+
});
349+
});
350+
351+
describe("error", () => {
352+
it("should use aria-invalid=false by default", () => {
353+
// Arrange
354+
355+
// Act
356+
doRender(
357+
<Combobox selectionType="single" value="">
358+
<OptionItem label="option 1" value="option1" />
359+
<OptionItem label="option 2" value="option2" />
360+
<OptionItem label="option 3" value="option3" />
361+
</Combobox>,
362+
);
363+
364+
// Assert
365+
expect(screen.getByRole("combobox")).toHaveAttribute(
366+
"aria-invalid",
367+
"false",
368+
);
369+
});
370+
371+
it("should use aria-invalid=true if error is true", async () => {
372+
// Arrange
373+
const userEvent = doRender(
374+
<Combobox selectionType="single" value="" error={true}>
375+
<OptionItem label="option 1" value="option1" />
376+
<OptionItem label="option 2" value="option2" />
377+
<OptionItem label="option 3" value="option3" />
378+
</Combobox>,
379+
);
380+
381+
// Act
382+
await userEvent.tab();
383+
384+
// Assert
385+
expect(screen.getByRole("combobox")).toHaveAttribute(
386+
"aria-invalid",
387+
"true",
388+
);
389+
});
390+
391+
it("should mark the combobox as aria-invalid=false when the value is valid", async () => {
392+
// Arrange
393+
const UnderTest = () => {
394+
const [value, setValue] =
395+
React.useState<MaybeValueOrValues>("");
396+
// empty value should mark the combobox as invalid
397+
const [error, setError] = React.useState(value === "");
398+
399+
return (
400+
<Combobox
401+
selectionType="single"
402+
value={value}
403+
error={error}
404+
onChange={(newValue) => {
405+
if (newValue) {
406+
setValue(newValue);
407+
}
408+
409+
setError(newValue === "");
410+
}}
411+
>
412+
<OptionItem label="option 1" value="option1" />
413+
<OptionItem label="option 2" value="option2" />
414+
<OptionItem label="option 3" value="option3" />
415+
</Combobox>
416+
);
417+
};
418+
419+
const userEvent = doRender(<UnderTest />);
420+
421+
// Act
422+
await userEvent.type(
423+
screen.getByRole("combobox"),
424+
"option 1{Enter}",
425+
);
426+
427+
// Assert
428+
expect(screen.getByRole("combobox")).toHaveAttribute(
429+
"aria-invalid",
430+
"false",
431+
);
432+
});
433+
});
434+
299435
describe("autoComplete", () => {
300436
it("should filter the options when typing", async () => {
301437
// Arrange
@@ -767,6 +903,29 @@ describe("Combobox", () => {
767903
defaultComboboxLabels.noItems,
768904
);
769905
});
906+
907+
// TODO (WB-1757.2): Enable this test once the LiveRegion component
908+
// is refactored.
909+
it.skip("should announce when the current selected value is cleared", async () => {
910+
// Arrange
911+
doRender(
912+
<Combobox value="option1" selectionType="single">
913+
<OptionItem label="Option 1" value="option1" />
914+
<OptionItem label="Option 2" value="option2" />
915+
<OptionItem label="Option 3" value="option3" />
916+
</Combobox>,
917+
);
918+
919+
// Act
920+
await userEvent.click(
921+
screen.getByRole("button", {name: /clear selection/i}),
922+
);
923+
924+
// Assert
925+
expect(screen.getByRole("log")).toHaveTextContent(
926+
defaultComboboxLabels.selectionCleared,
927+
);
928+
});
770929
});
771930
});
772931
});

packages/wonder-blocks-dropdown/src/components/combobox-live-region.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Props = {
3131
| "liveRegionMultipleSelectionTotal"
3232
| "noItems"
3333
| "selected"
34+
| "selectionCleared"
3435
| "unselected"
3536
>;
3637

@@ -61,6 +62,7 @@ type Props = {
6162
testId?: string;
6263
};
6364

65+
// TODO (WB-1757.2): Refactor this component to use hooks + Context.
6466
/**
6567
* A component that announces focus changes to Screen Readers.
6668
*
@@ -81,6 +83,7 @@ export function ComboboxLiveRegion({
8183
defaultComboboxLabels.liveRegionMultipleSelectionTotal,
8284
noItems: defaultComboboxLabels.noItems,
8385
selected: defaultComboboxLabels.selected,
86+
selectionCleared: defaultComboboxLabels.selectionCleared,
8487
unselected: defaultComboboxLabels.unselected,
8588
},
8689
selectedLabels,
@@ -117,6 +120,16 @@ export function ComboboxLiveRegion({
117120
setMessage(newMessage);
118121
}
119122

123+
// Announce when the single-select value is cleared.
124+
// NOTE: It only applies after the user has selected an option.
125+
if (
126+
selectionType === "single" &&
127+
!selected &&
128+
lastSelectedValue.current
129+
) {
130+
setMessage(labels.selectionCleared);
131+
}
132+
120133
lastSelectedValue.current = selected;
121134

122135
if (selectionType === "multiple" && !opened) {

0 commit comments

Comments
 (0)