@simplybusiness/mobius 9.0.1 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/cjs/index.js +5481 -3973
- package/dist/cjs/index.js.map +4 -4
- package/dist/cjs/meta.json +444 -201
- package/dist/esm/MaskedField-CX4GV6JS.js +142 -0
- package/dist/esm/MaskedField-CX4GV6JS.js.map +7 -0
- package/dist/esm/chunk-XUNHMVIS.js +427 -0
- package/dist/esm/chunk-XUNHMVIS.js.map +7 -0
- package/dist/esm/index.js +580 -1029
- package/dist/esm/index.js.map +4 -4
- package/dist/esm/meta.json +258 -163
- package/dist/esm/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/src/components/Combobox/Listbox.d.ts +2 -1
- package/dist/types/src/components/Combobox/Option.d.ts +1 -1
- package/dist/types/src/components/Combobox/index.d.ts +1 -0
- package/dist/types/src/components/Combobox/types.d.ts +3 -0
- package/dist/types/src/components/Combobox/utils.d.ts +1 -0
- package/dist/types/src/components/MaskedField/MaskedField.d.ts +1 -0
- package/dist/types/src/components/MaskedField/index.d.ts +6 -1
- package/package.json +5 -2
- package/src/components/Combobox/Combobox.test.tsx +164 -3
- package/src/components/Combobox/Combobox.tsx +54 -4
- package/src/components/Combobox/Listbox.tsx +4 -0
- package/src/components/Combobox/Option.tsx +8 -1
- package/src/components/Combobox/index.tsx +1 -0
- package/src/components/Combobox/types.tsx +3 -0
- package/src/components/Combobox/utils.test.tsx +39 -0
- package/src/components/Combobox/utils.tsx +7 -0
- package/src/components/Drawer/Drawer.mdx +1 -1
- package/src/components/Drawer/Drawer.stories.tsx +1 -1
- package/src/components/ErrorMessage/ErrorMessage.css +1 -1
- package/src/components/MaskedField/MaskedField.tsx +1 -0
- package/src/components/MaskedField/index.tsx +42 -1
|
@@ -8,5 +8,6 @@ export type ListboxProps<T extends ComboboxOption> = {
|
|
|
8
8
|
highlightedGroupIndex: number;
|
|
9
9
|
onOptionSelect: (option: T) => void;
|
|
10
10
|
optionComponent?: ComboboxBaseProps<T>["optionComponent"];
|
|
11
|
+
optionTestIdPrefix?: string;
|
|
11
12
|
};
|
|
12
|
-
export declare const Listbox: <T extends ComboboxOption>({ id, isOpen, options, highlightedIndex, highlightedGroupIndex, onOptionSelect, optionComponent, }: ListboxProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare const Listbox: <T extends ComboboxOption>({ id, isOpen, options, highlightedIndex, highlightedGroupIndex, onOptionSelect, optionComponent, optionTestIdPrefix, }: ListboxProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { ComboboxOption, ComboboxOptionProps } from "./types";
|
|
2
|
-
export declare const Option: <T extends ComboboxOption>({ option, isHighlighted, onOptionSelect, optionComponent: OptionComponent, id, }: ComboboxOptionProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
2
|
+
export declare const Option: <T extends ComboboxOption>({ option, isHighlighted, onOptionSelect, optionComponent: OptionComponent, optionTestIdPrefix, id, }: ComboboxOptionProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -13,6 +13,8 @@ export type ComboboxBaseProps<T extends ComboboxOption = ComboboxOption> = TextF
|
|
|
13
13
|
/** Callback when the selected option changes */
|
|
14
14
|
onSelected?: ((value: T) => void) | undefined;
|
|
15
15
|
optionComponent?: (props: Pick<ComboboxOptionProps<T>, "option" | "isHighlighted">) => ReactElement;
|
|
16
|
+
/** Prefix for option data-testid attributes (default: "combobox-option") */
|
|
17
|
+
optionTestIdPrefix?: string;
|
|
16
18
|
};
|
|
17
19
|
export type ComboboxSyncProps<T extends ComboboxOption = ComboboxOption> = ComboboxBaseProps<T> & {
|
|
18
20
|
/** The list of options to display in the dropdown */
|
|
@@ -46,4 +48,5 @@ export type ComboboxOptionProps<T extends ComboboxOption = ComboboxOption> = {
|
|
|
46
48
|
isHighlighted: boolean;
|
|
47
49
|
onOptionSelect: (option: T) => void;
|
|
48
50
|
id: string;
|
|
51
|
+
optionTestIdPrefix?: string;
|
|
49
52
|
};
|
|
@@ -4,3 +4,4 @@ export declare const getOptionValue: (option: ComboboxOption | undefined) => str
|
|
|
4
4
|
export declare const getOptionLabel: (option: ComboboxOption | undefined) => string | undefined;
|
|
5
5
|
export declare function filterOptions<T extends ComboboxOption>(options: ComboboxOptions<T>, inputValue: string): ComboboxOptions<T>;
|
|
6
6
|
export declare function clamp(value: number, min: number, max: number): number;
|
|
7
|
+
export declare const buildOptionTestId: (prefix: string, value: string) => string;
|
|
@@ -4,6 +4,7 @@ import type { TextFieldProps } from "../TextField";
|
|
|
4
4
|
export type MaskedFieldElementType = HTMLInputElement;
|
|
5
5
|
export interface MaskedFieldProps extends Omit<TextFieldProps, "type" | "ref">, RefAttributes<MaskedFieldElementType> {
|
|
6
6
|
mask: FactoryOpts;
|
|
7
|
+
"data-testid"?: string;
|
|
7
8
|
/**
|
|
8
9
|
* If true, onChange and onBlur events will emit the masked (formatted) value.
|
|
9
10
|
* If false (default), events will emit the unmasked (raw) value.
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import type { MaskedFieldProps } from "./MaskedField";
|
|
2
|
+
export declare function MaskedField(props: MaskedFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
3
|
+
export declare namespace MaskedField {
|
|
4
|
+
var displayName: string;
|
|
5
|
+
}
|
|
6
|
+
export type { MaskedFieldProps, MaskedFieldRef, MaskedFieldElementType, } from "./MaskedField";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplybusiness/mobius",
|
|
3
3
|
"license": "UNLICENSED",
|
|
4
|
-
"version": "9.0
|
|
4
|
+
"version": "9.1.0",
|
|
5
5
|
"description": "Core library of Mobius react components",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
"lint:css": "lint-css",
|
|
57
57
|
"lint:css:fix": "lint-css --fix"
|
|
58
58
|
},
|
|
59
|
-
"sideEffects":
|
|
59
|
+
"sideEffects": [
|
|
60
|
+
"src/**/*.css"
|
|
61
|
+
],
|
|
60
62
|
"devDependencies": {
|
|
61
63
|
"@eslint/compat": "^2.0.2",
|
|
62
64
|
"@eslint/eslintrc": "^3.3.4",
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
},
|
|
93
95
|
"dependencies": {
|
|
94
96
|
"@floating-ui/react": "^0.27.18",
|
|
97
|
+
"@loadable/component": "^5.16.7",
|
|
95
98
|
"@simplybusiness/icons": "^5.0.3",
|
|
96
99
|
"@simplybusiness/mobius-hooks": "^0.1.1",
|
|
97
100
|
"classnames": "^2.5.1",
|
|
@@ -127,6 +127,32 @@ describe("Combobox", () => {
|
|
|
127
127
|
expect(ids.size).toBe(getFlatOptions(options).length);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("should give each option a data-testid based on its value", () => {
|
|
131
|
+
render(<Combobox label="Fruit" options={options} />);
|
|
132
|
+
const input = screen.getByRole("combobox");
|
|
133
|
+
fireEvent.mouseDown(input);
|
|
134
|
+
fireEvent.focus(input);
|
|
135
|
+
const firstOption = screen.getAllByRole("option")[0];
|
|
136
|
+
const testId = firstOption.getAttribute("data-testid");
|
|
137
|
+
expect(testId).toMatch(/^combobox-option-.+$/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should use a custom optionTestIdPrefix when provided", () => {
|
|
141
|
+
render(
|
|
142
|
+
<Combobox
|
|
143
|
+
label="Fruit"
|
|
144
|
+
options={options}
|
|
145
|
+
optionTestIdPrefix="trade-option"
|
|
146
|
+
/>,
|
|
147
|
+
);
|
|
148
|
+
const input = screen.getByRole("combobox");
|
|
149
|
+
fireEvent.mouseDown(input);
|
|
150
|
+
fireEvent.focus(input);
|
|
151
|
+
const firstOption = screen.getAllByRole("option")[0];
|
|
152
|
+
const testId = firstOption.getAttribute("data-testid");
|
|
153
|
+
expect(testId).toMatch(/^trade-option-.+$/);
|
|
154
|
+
});
|
|
155
|
+
|
|
130
156
|
it("should render custom option component when optionComponent is provided", async () => {
|
|
131
157
|
const CustomOption = ({
|
|
132
158
|
option,
|
|
@@ -212,7 +238,7 @@ describe("Combobox", () => {
|
|
|
212
238
|
expect(onSelected).toHaveBeenCalledWith(getFirstOption(options));
|
|
213
239
|
});
|
|
214
240
|
|
|
215
|
-
it("should
|
|
241
|
+
it("should show validation error and clear input when a user blurs with invalid text", async () => {
|
|
216
242
|
const onSelected = vi.fn();
|
|
217
243
|
render(
|
|
218
244
|
<Combobox
|
|
@@ -224,8 +250,69 @@ describe("Combobox", () => {
|
|
|
224
250
|
const input = screen.getByRole("combobox");
|
|
225
251
|
await user.click(input);
|
|
226
252
|
await user.type(input, "app");
|
|
227
|
-
await
|
|
228
|
-
|
|
253
|
+
await act(async () => {
|
|
254
|
+
await user.tab();
|
|
255
|
+
vi.advanceTimersByTime(200);
|
|
256
|
+
});
|
|
257
|
+
await vi.waitFor(() => {
|
|
258
|
+
expect(onSelected).not.toHaveBeenCalled();
|
|
259
|
+
expect(input).toHaveValue("");
|
|
260
|
+
expect(
|
|
261
|
+
screen.getByText("Please select an option from the list"),
|
|
262
|
+
).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should call onSelected with empty value when user clears input and blurs", async () => {
|
|
267
|
+
const onSelected = vi.fn();
|
|
268
|
+
render(
|
|
269
|
+
<Combobox
|
|
270
|
+
label="Fruit"
|
|
271
|
+
options={options}
|
|
272
|
+
onSelected={onSelected}
|
|
273
|
+
defaultValue="Apple"
|
|
274
|
+
/>,
|
|
275
|
+
);
|
|
276
|
+
const input = screen.getByRole("combobox");
|
|
277
|
+
await user.click(input);
|
|
278
|
+
await user.clear(input);
|
|
279
|
+
await act(async () => {
|
|
280
|
+
await user.tab();
|
|
281
|
+
vi.advanceTimersByTime(200);
|
|
282
|
+
});
|
|
283
|
+
await vi.waitFor(() => {
|
|
284
|
+
// String options emit "", object options emit { label: "", value: "" }
|
|
285
|
+
const firstOption = getFirstOption(options);
|
|
286
|
+
const expectedEmpty =
|
|
287
|
+
typeof firstOption === "string" ? "" : { label: "", value: "" };
|
|
288
|
+
expect(onSelected).toHaveBeenCalledWith(expectedEmpty);
|
|
289
|
+
expect(input).toHaveValue("");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("should clear validation error when user starts typing again", async () => {
|
|
294
|
+
render(<Combobox label="Fruit" options={options} />);
|
|
295
|
+
const input = screen.getByRole("combobox");
|
|
296
|
+
await user.click(input);
|
|
297
|
+
await user.type(input, "xyz");
|
|
298
|
+
await act(async () => {
|
|
299
|
+
await user.tab();
|
|
300
|
+
vi.advanceTimersByTime(200);
|
|
301
|
+
});
|
|
302
|
+
await vi.waitFor(() => {
|
|
303
|
+
expect(
|
|
304
|
+
screen.getByText("Please select an option from the list"),
|
|
305
|
+
).toBeInTheDocument();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Start typing again
|
|
309
|
+
await user.click(input);
|
|
310
|
+
await user.type(input, "a");
|
|
311
|
+
await vi.waitFor(() => {
|
|
312
|
+
expect(
|
|
313
|
+
screen.queryByText("Please select an option from the list"),
|
|
314
|
+
).not.toBeInTheDocument();
|
|
315
|
+
});
|
|
229
316
|
});
|
|
230
317
|
|
|
231
318
|
it("should update the input value when an option is selected", async () => {
|
|
@@ -798,6 +885,80 @@ describe("Combobox", () => {
|
|
|
798
885
|
const wrapper = screen.getByTestId("mobius-combobox__wrapper");
|
|
799
886
|
expect(wrapper).not.toHaveClass("mobius-combobox--is-loading");
|
|
800
887
|
});
|
|
888
|
+
|
|
889
|
+
it("should call onSelected with empty value when user clears async input and blurs", async () => {
|
|
890
|
+
const onSelected = vi.fn();
|
|
891
|
+
render(
|
|
892
|
+
<Combobox
|
|
893
|
+
label="Fruit"
|
|
894
|
+
asyncOptions={asyncOptions}
|
|
895
|
+
onSelected={onSelected}
|
|
896
|
+
defaultValue="Apple"
|
|
897
|
+
/>,
|
|
898
|
+
);
|
|
899
|
+
const input = screen.getByRole("combobox");
|
|
900
|
+
await user.click(input);
|
|
901
|
+
await user.clear(input);
|
|
902
|
+
await user.tab();
|
|
903
|
+
act(() => {
|
|
904
|
+
vi.advanceTimersByTime(200);
|
|
905
|
+
});
|
|
906
|
+
await vi.waitFor(() => {
|
|
907
|
+
// Async combobox needs to infer type - check what was actually called
|
|
908
|
+
// For empty values, it looks at the first option from the last loaded set
|
|
909
|
+
// Since we're testing across different option types, just verify empty was called
|
|
910
|
+
expect(onSelected).toHaveBeenCalled();
|
|
911
|
+
const callArg = onSelected.mock.calls[0][0];
|
|
912
|
+
// Verify it's an empty value (either "" or { label: "", value: "" })
|
|
913
|
+
if (typeof callArg === "string") {
|
|
914
|
+
expect(callArg).toBe("");
|
|
915
|
+
} else {
|
|
916
|
+
expect(callArg).toEqual({ label: "", value: "" });
|
|
917
|
+
}
|
|
918
|
+
expect(input).toHaveValue("");
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("should show validation error when user enters invalid text in async combobox", async () => {
|
|
923
|
+
const onSelected = vi.fn();
|
|
924
|
+
render(
|
|
925
|
+
<Combobox
|
|
926
|
+
label="Fruit"
|
|
927
|
+
asyncOptions={asyncOptions}
|
|
928
|
+
onSelected={onSelected}
|
|
929
|
+
/>,
|
|
930
|
+
);
|
|
931
|
+
const input = screen.getByRole("combobox");
|
|
932
|
+
await user.click(input);
|
|
933
|
+
await user.type(input, "berry");
|
|
934
|
+
|
|
935
|
+
// Wait for async options to load
|
|
936
|
+
await act(async () => {
|
|
937
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// Clear and type invalid text
|
|
941
|
+
await user.clear(input);
|
|
942
|
+
await user.type(input, "xyz");
|
|
943
|
+
|
|
944
|
+
// Wait for async to complete with no results
|
|
945
|
+
await act(async () => {
|
|
946
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// Blur to trigger validation
|
|
950
|
+
await user.tab();
|
|
951
|
+
act(() => {
|
|
952
|
+
vi.advanceTimersByTime(200);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
await vi.waitFor(() => {
|
|
956
|
+
expect(
|
|
957
|
+
screen.getByText("Please select an option from the list"),
|
|
958
|
+
).toBeInTheDocument();
|
|
959
|
+
expect(input).toHaveValue("");
|
|
960
|
+
});
|
|
961
|
+
});
|
|
801
962
|
});
|
|
802
963
|
});
|
|
803
964
|
});
|
|
@@ -33,6 +33,7 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
33
33
|
onChange,
|
|
34
34
|
// onSearched, // unused prop, consider removing
|
|
35
35
|
optionComponent,
|
|
36
|
+
optionTestIdPrefix,
|
|
36
37
|
errorMessage,
|
|
37
38
|
...otherProps
|
|
38
39
|
} = props;
|
|
@@ -51,6 +52,9 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
51
52
|
minSearchLength,
|
|
52
53
|
skipNextDebounceRef,
|
|
53
54
|
});
|
|
55
|
+
const [validationError, setValidationError] = useState(
|
|
56
|
+
error?.message || errorMessage,
|
|
57
|
+
);
|
|
54
58
|
const {
|
|
55
59
|
highlightedIndex,
|
|
56
60
|
highlightedGroupIndex,
|
|
@@ -70,6 +74,32 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
70
74
|
const { down } = useBreakpoint();
|
|
71
75
|
const isMobile = down("md");
|
|
72
76
|
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setValidationError(error?.message || errorMessage);
|
|
79
|
+
}, [error, errorMessage]);
|
|
80
|
+
|
|
81
|
+
// Helper to create properly-typed empty value based on option type
|
|
82
|
+
const getEmptyValue = (): T => {
|
|
83
|
+
// Check first available option to determine if we're using string or object options
|
|
84
|
+
const firstOption = filteredOptions
|
|
85
|
+
? isOptionGroup(filteredOptions)
|
|
86
|
+
? filteredOptions[0]?.options[0]
|
|
87
|
+
: filteredOptions[0]
|
|
88
|
+
: options
|
|
89
|
+
? isOptionGroup(options)
|
|
90
|
+
? options[0]?.options[0]
|
|
91
|
+
: options[0]
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
// If options are strings, return empty string
|
|
95
|
+
if (typeof firstOption === "string") {
|
|
96
|
+
return "" as T;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If options are objects, return empty object with same shape
|
|
100
|
+
return { label: "", value: "" } as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
73
103
|
const handleFocus = (e: FocusEvent) => {
|
|
74
104
|
onFocus?.(e);
|
|
75
105
|
if (!filteredOptions || filteredOptions.length === 0) return;
|
|
@@ -122,6 +152,7 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
122
152
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
123
153
|
const newValue = e.target.value;
|
|
124
154
|
setInputValue(newValue);
|
|
155
|
+
setValidationError(undefined);
|
|
125
156
|
justSelectedRef.current = false;
|
|
126
157
|
setIsChanging(true);
|
|
127
158
|
// Only open immediately for sync options; async options controlled by useEffect
|
|
@@ -134,7 +165,9 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
134
165
|
|
|
135
166
|
const handleOptionSelect = (option: T) => {
|
|
136
167
|
const val = getOptionValue(option);
|
|
137
|
-
|
|
168
|
+
|
|
169
|
+
// Allow empty values to pass through
|
|
170
|
+
if (!val && val !== "") return;
|
|
138
171
|
|
|
139
172
|
if (
|
|
140
173
|
typeof option === "object" &&
|
|
@@ -156,6 +189,7 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
156
189
|
justSelectedRef.current = true;
|
|
157
190
|
|
|
158
191
|
setIsChanging(false);
|
|
192
|
+
setValidationError(undefined);
|
|
159
193
|
setIsOpen(false);
|
|
160
194
|
setInputValue(val);
|
|
161
195
|
onSelected?.(option);
|
|
@@ -197,14 +231,29 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
197
231
|
// Force selection if user has matched an entry by typing (not already selected)
|
|
198
232
|
// Defer this to allow natural focus flow to complete first
|
|
199
233
|
if (!justSelectedRef.current) {
|
|
200
|
-
const typedText = inputValue.trim()
|
|
234
|
+
const typedText = inputValue.trim();
|
|
235
|
+
const typedTextLower = typedText.toLowerCase();
|
|
201
236
|
const highlightedOption = getHighlightedOption();
|
|
202
237
|
const label = getOptionLabel(highlightedOption);
|
|
203
238
|
|
|
204
|
-
if (
|
|
239
|
+
if (typedTextLower === label?.toLowerCase()) {
|
|
240
|
+
// Exact match with an option
|
|
205
241
|
setTimeout(() => {
|
|
206
242
|
handleOptionSelect(highlightedOption as T);
|
|
207
243
|
}, 0);
|
|
244
|
+
} else if (typedText === "") {
|
|
245
|
+
// Allow empty values
|
|
246
|
+
setTimeout(() => {
|
|
247
|
+
handleOptionSelect(getEmptyValue());
|
|
248
|
+
}, 0);
|
|
249
|
+
} else {
|
|
250
|
+
// Invalid value (not in options and not empty)
|
|
251
|
+
setValidationError(
|
|
252
|
+
errorMessage || "Please select an option from the list",
|
|
253
|
+
);
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
setInputValue("");
|
|
256
|
+
}, 0);
|
|
208
257
|
}
|
|
209
258
|
}
|
|
210
259
|
|
|
@@ -330,7 +379,7 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
330
379
|
}
|
|
331
380
|
prefixInside={icon}
|
|
332
381
|
ref={inputRef}
|
|
333
|
-
errorMessage={errorMessage || error?.message
|
|
382
|
+
errorMessage={errorMessage || validationError || error?.message}
|
|
334
383
|
/>
|
|
335
384
|
<Listbox
|
|
336
385
|
id={listboxId}
|
|
@@ -341,6 +390,7 @@ const ComboboxInner = <T extends ComboboxOption>({
|
|
|
341
390
|
highlightedGroupIndex={highlightedGroupIndex}
|
|
342
391
|
onOptionSelect={handleOptionSelect}
|
|
343
392
|
optionComponent={optionComponent}
|
|
393
|
+
optionTestIdPrefix={optionTestIdPrefix}
|
|
344
394
|
/>
|
|
345
395
|
</div>
|
|
346
396
|
);
|
|
@@ -30,6 +30,7 @@ export type ListboxProps<T extends ComboboxOption> = {
|
|
|
30
30
|
highlightedGroupIndex: number;
|
|
31
31
|
onOptionSelect: (option: T) => void;
|
|
32
32
|
optionComponent?: ComboboxBaseProps<T>["optionComponent"];
|
|
33
|
+
optionTestIdPrefix?: string;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export const Listbox = <T extends ComboboxOption>({
|
|
@@ -40,6 +41,7 @@ export const Listbox = <T extends ComboboxOption>({
|
|
|
40
41
|
highlightedGroupIndex,
|
|
41
42
|
onOptionSelect,
|
|
42
43
|
optionComponent,
|
|
44
|
+
optionTestIdPrefix,
|
|
43
45
|
}: ListboxProps<T>) => {
|
|
44
46
|
const classes = classNames("mobius-combobox__list", {
|
|
45
47
|
"mobius-combobox__list--hidden": !isOpen,
|
|
@@ -102,6 +104,7 @@ export const Listbox = <T extends ComboboxOption>({
|
|
|
102
104
|
}
|
|
103
105
|
onOptionSelect={onOptionSelect}
|
|
104
106
|
optionComponent={optionComponent}
|
|
107
|
+
optionTestIdPrefix={optionTestIdPrefix}
|
|
105
108
|
id={getOptionId(groupOption, groupIndex, index)}
|
|
106
109
|
/>
|
|
107
110
|
))}
|
|
@@ -115,6 +118,7 @@ export const Listbox = <T extends ComboboxOption>({
|
|
|
115
118
|
isHighlighted={highlightedIndex === index}
|
|
116
119
|
onOptionSelect={onOptionSelect}
|
|
117
120
|
optionComponent={optionComponent}
|
|
121
|
+
optionTestIdPrefix={optionTestIdPrefix}
|
|
118
122
|
id={getOptionId(option, 0, index)}
|
|
119
123
|
/>
|
|
120
124
|
))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import classNames from "classnames/dedupe";
|
|
3
|
-
import { getOptionValue, getOptionLabel } from "./utils";
|
|
3
|
+
import { getOptionValue, getOptionLabel, buildOptionTestId } from "./utils";
|
|
4
4
|
import type { ComboboxOption, ComboboxOptionProps } from "./types";
|
|
5
5
|
|
|
6
6
|
export const Option = <T extends ComboboxOption>({
|
|
@@ -8,9 +8,15 @@ export const Option = <T extends ComboboxOption>({
|
|
|
8
8
|
isHighlighted,
|
|
9
9
|
onOptionSelect,
|
|
10
10
|
optionComponent: OptionComponent,
|
|
11
|
+
optionTestIdPrefix,
|
|
11
12
|
id,
|
|
12
13
|
}: ComboboxOptionProps<T>) => {
|
|
13
14
|
const optionRef = useRef<HTMLLIElement>(null);
|
|
15
|
+
const optionValue = getOptionValue(option) || "";
|
|
16
|
+
const testId = buildOptionTestId(
|
|
17
|
+
optionTestIdPrefix || "combobox-option",
|
|
18
|
+
optionValue,
|
|
19
|
+
);
|
|
14
20
|
|
|
15
21
|
useEffect(() => {
|
|
16
22
|
if (
|
|
@@ -26,6 +32,7 @@ export const Option = <T extends ComboboxOption>({
|
|
|
26
32
|
<li
|
|
27
33
|
ref={optionRef}
|
|
28
34
|
role="option"
|
|
35
|
+
data-testid={testId}
|
|
29
36
|
key={getOptionValue(option)}
|
|
30
37
|
id={id}
|
|
31
38
|
aria-selected={isHighlighted}
|
|
@@ -16,6 +16,8 @@ export type ComboboxBaseProps<T extends ComboboxOption = ComboboxOption> =
|
|
|
16
16
|
optionComponent?: (
|
|
17
17
|
props: Pick<ComboboxOptionProps<T>, "option" | "isHighlighted">,
|
|
18
18
|
) => ReactElement;
|
|
19
|
+
/** Prefix for option data-testid attributes (default: "combobox-option") */
|
|
20
|
+
optionTestIdPrefix?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export type ComboboxSyncProps<T extends ComboboxOption = ComboboxOption> =
|
|
@@ -66,4 +68,5 @@ export type ComboboxOptionProps<T extends ComboboxOption = ComboboxOption> = {
|
|
|
66
68
|
isHighlighted: boolean;
|
|
67
69
|
onOptionSelect: (option: T) => void;
|
|
68
70
|
id: string;
|
|
71
|
+
optionTestIdPrefix?: string;
|
|
69
72
|
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getOptionValue,
|
|
5
5
|
filterOptions,
|
|
6
6
|
clamp,
|
|
7
|
+
buildOptionTestId,
|
|
7
8
|
} from "./utils";
|
|
8
9
|
import type { ComboboxOptions } from "./types";
|
|
9
10
|
|
|
@@ -104,6 +105,44 @@ describe("Combobox utils", () => {
|
|
|
104
105
|
});
|
|
105
106
|
});
|
|
106
107
|
|
|
108
|
+
describe("buildOptionTestId", () => {
|
|
109
|
+
it("should build a test ID from prefix and value", () => {
|
|
110
|
+
expect(buildOptionTestId("combobox-option", "Florist")).toBe(
|
|
111
|
+
"combobox-option-florist",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should replace spaces with hyphens", () => {
|
|
116
|
+
expect(buildOptionTestId("trade-option", "Dog Walking")).toBe(
|
|
117
|
+
"trade-option-dog-walking",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should handle multiple consecutive spaces", () => {
|
|
122
|
+
expect(buildOptionTestId("combobox-option", "Foo Bar")).toBe(
|
|
123
|
+
"combobox-option-foo-bar",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should replace punctuation with hyphens", () => {
|
|
128
|
+
expect(buildOptionTestId("trade-option", "Painter & Decorator")).toBe(
|
|
129
|
+
"trade-option-painter-decorator",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should handle slashes and special characters", () => {
|
|
134
|
+
expect(
|
|
135
|
+
buildOptionTestId("trade-option", "Coffee Shop / Sandwich Bar"),
|
|
136
|
+
).toBe("trade-option-coffee-shop-sandwich-bar");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should trim leading and trailing whitespace", () => {
|
|
140
|
+
expect(buildOptionTestId("combobox-option", " Florist ")).toBe(
|
|
141
|
+
"combobox-option-florist",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
107
146
|
describe("clamp", () => {
|
|
108
147
|
it("should clamp to the min value", () => {
|
|
109
148
|
expect(clamp(-1, 0, 10)).toBe(0);
|
|
@@ -49,3 +49,10 @@ export function filterOptions<T extends ComboboxOption>(
|
|
|
49
49
|
export function clamp(value: number, min: number, max: number) {
|
|
50
50
|
return Math.min(Math.max(value, min), max);
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
export const buildOptionTestId = (prefix: string, value: string): string =>
|
|
54
|
+
`${prefix}-${value
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "")}`;
|
|
@@ -82,7 +82,7 @@ const LogoHeader = () => {
|
|
|
82
82
|
>
|
|
83
83
|
<Drawer.Header>
|
|
84
84
|
<img
|
|
85
|
-
src="https://quote.simplybusiness.com/assets/logos/insurers/harborway_insurance
|
|
85
|
+
src="https://quote.simplybusiness.com/assets/logos/insurers/harborway_insurance.png"
|
|
86
86
|
alt="Provider logo"
|
|
87
87
|
/>
|
|
88
88
|
</Drawer.Header>
|
|
@@ -72,7 +72,7 @@ const LogoHeader = ({ direction, closeLabel }: DrawerProps) => {
|
|
|
72
72
|
>
|
|
73
73
|
<Drawer.Header>
|
|
74
74
|
<img
|
|
75
|
-
src="https://quote.simplybusiness.com/assets/logos/insurers/harborway_insurance
|
|
75
|
+
src="https://quote.simplybusiness.com/assets/logos/insurers/harborway_insurance.png"
|
|
76
76
|
alt="Provider logo"
|
|
77
77
|
/>
|
|
78
78
|
</Drawer.Header>
|
|
@@ -14,6 +14,7 @@ export interface MaskedFieldProps
|
|
|
14
14
|
Omit<TextFieldProps, "type" | "ref">,
|
|
15
15
|
RefAttributes<MaskedFieldElementType> {
|
|
16
16
|
mask: FactoryOpts;
|
|
17
|
+
"data-testid"?: string;
|
|
17
18
|
/**
|
|
18
19
|
* If true, onChange and onBlur events will emit the masked (formatted) value.
|
|
19
20
|
* If false (default), events will emit the unmasked (raw) value.
|
|
@@ -1 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import loadable from "@loadable/component";
|
|
4
|
+
import { TextField } from "../TextField";
|
|
5
|
+
import type { MaskedFieldProps } from "./MaskedField";
|
|
6
|
+
|
|
7
|
+
/** Lazy-loads MaskedField (and react-imask) so consumers importing only TextField don't bundle react-imask. */
|
|
8
|
+
const LoadableMaskedField = loadable(() => import("./MaskedField"), {
|
|
9
|
+
resolveComponent: mod => mod.MaskedField,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function MaskedField(props: MaskedFieldProps) {
|
|
13
|
+
const {
|
|
14
|
+
mask: _mask,
|
|
15
|
+
useMaskedValue: _useMaskedValue,
|
|
16
|
+
"data-testid": _dataTestId,
|
|
17
|
+
ref: forwardedRef,
|
|
18
|
+
...textFieldProps
|
|
19
|
+
} = props;
|
|
20
|
+
return (
|
|
21
|
+
<LoadableMaskedField
|
|
22
|
+
{...props}
|
|
23
|
+
fallback={
|
|
24
|
+
<TextField
|
|
25
|
+
{...textFieldProps}
|
|
26
|
+
ref={forwardedRef}
|
|
27
|
+
type="text"
|
|
28
|
+
isDisabled
|
|
29
|
+
isReadOnly
|
|
30
|
+
/>
|
|
31
|
+
}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
MaskedField.displayName = "MaskedField";
|
|
37
|
+
|
|
38
|
+
export type {
|
|
39
|
+
MaskedFieldProps,
|
|
40
|
+
MaskedFieldRef,
|
|
41
|
+
MaskedFieldElementType,
|
|
42
|
+
} from "./MaskedField";
|