@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cjs/index.js +5481 -3973
  3. package/dist/cjs/index.js.map +4 -4
  4. package/dist/cjs/meta.json +444 -201
  5. package/dist/esm/MaskedField-CX4GV6JS.js +142 -0
  6. package/dist/esm/MaskedField-CX4GV6JS.js.map +7 -0
  7. package/dist/esm/chunk-XUNHMVIS.js +427 -0
  8. package/dist/esm/chunk-XUNHMVIS.js.map +7 -0
  9. package/dist/esm/index.js +580 -1029
  10. package/dist/esm/index.js.map +4 -4
  11. package/dist/esm/meta.json +258 -163
  12. package/dist/esm/tsconfig.build.tsbuildinfo +1 -1
  13. package/dist/types/src/components/Combobox/Listbox.d.ts +2 -1
  14. package/dist/types/src/components/Combobox/Option.d.ts +1 -1
  15. package/dist/types/src/components/Combobox/index.d.ts +1 -0
  16. package/dist/types/src/components/Combobox/types.d.ts +3 -0
  17. package/dist/types/src/components/Combobox/utils.d.ts +1 -0
  18. package/dist/types/src/components/MaskedField/MaskedField.d.ts +1 -0
  19. package/dist/types/src/components/MaskedField/index.d.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/components/Combobox/Combobox.test.tsx +164 -3
  22. package/src/components/Combobox/Combobox.tsx +54 -4
  23. package/src/components/Combobox/Listbox.tsx +4 -0
  24. package/src/components/Combobox/Option.tsx +8 -1
  25. package/src/components/Combobox/index.tsx +1 -0
  26. package/src/components/Combobox/types.tsx +3 -0
  27. package/src/components/Combobox/utils.test.tsx +39 -0
  28. package/src/components/Combobox/utils.tsx +7 -0
  29. package/src/components/Drawer/Drawer.mdx +1 -1
  30. package/src/components/Drawer/Drawer.stories.tsx +1 -1
  31. package/src/components/ErrorMessage/ErrorMessage.css +1 -1
  32. package/src/components/MaskedField/MaskedField.tsx +1 -0
  33. 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;
@@ -1,2 +1,3 @@
1
1
  export * from "./Combobox";
2
2
  export * from "./types";
3
+ export { buildOptionTestId } from "./utils";
@@ -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
- export * from "./MaskedField";
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.1",
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": false,
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 NOT call the onSelected callback when a user blurs and the typed text does not match an option label", async () => {
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 user.tab();
228
- expect(onSelected).not.toHaveBeenCalled();
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
- if (!val) return;
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().toLowerCase();
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 (typedText === label?.toLowerCase()) {
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 || undefined}
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}
@@ -1,2 +1,3 @@
1
1
  export * from "./Combobox";
2
2
  export * from "./types";
3
+ export { buildOptionTestId } from "./utils";
@@ -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-1a46ba508f3dfcc89aa2498e7680ed15bd1704ccb01c614eb5335bd7b25e0537.png"
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-1a46ba508f3dfcc89aa2498e7680ed15bd1704ccb01c614eb5335bd7b25e0537.png"
75
+ src="https://quote.simplybusiness.com/assets/logos/insurers/harborway_insurance.png"
76
76
  alt="Provider logo"
77
77
  />
78
78
  </Drawer.Header>
@@ -4,7 +4,7 @@
4
4
  gap: var(--error-message-grid-gap, var(--size-xs));
5
5
  grid-template-columns: min-content 1fr;
6
6
  color: var(--color-error);
7
-
7
+
8
8
  & a {
9
9
  color: var(--color-link);
10
10
  outline: none;
@@ -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
- export * from "./MaskedField";
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";