@simplybusiness/mobius 6.5.2 → 6.5.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/mobius",
3
3
  "license": "UNLICENSED",
4
- "version": "6.5.2",
4
+ "version": "6.5.3",
5
5
  "description": "Core library of Mobius react components",
6
6
  "repository": {
7
7
  "type": "git",
@@ -676,8 +676,8 @@ describe("Combobox", () => {
676
676
  render(<Combobox label="Fruit" options={options} />);
677
677
  const input = screen.getByRole("combobox");
678
678
  await user.tab();
679
- const visibleOptions = screen.getAllByRole("option");
680
679
  await user.keyboard("{arrowdown}");
680
+ const visibleOptions = screen.getAllByRole("option");
681
681
  expect(input).toHaveAttribute(
682
682
  "aria-activedescendant",
683
683
  `${visibleOptions[0].id}`,
@@ -62,6 +62,8 @@ const ComboboxInner = forwardRef(
62
62
  const listboxId = useId();
63
63
  const statusId = useId();
64
64
  const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
65
+ const userInteractedRef = useRef(false);
66
+ const justSelectedRef = useRef(false);
65
67
  const { down } = useBreakpoint();
66
68
  const isMobile = down("md");
67
69
 
@@ -72,9 +74,43 @@ const ComboboxInner = forwardRef(
72
74
  clearTimeout(blurTimeoutRef.current);
73
75
  blurTimeoutRef.current = null;
74
76
  }
75
- setIsOpen(true);
77
+
78
+ // Check if this is natural focus (user click/Tab) or programmatic focus
79
+ const isNaturalFocus =
80
+ userInteractedRef.current || e.relatedTarget !== null;
81
+ if (userInteractedRef.current) {
82
+ userInteractedRef.current = false;
83
+ }
84
+
85
+ // Block opening only if programmatic focus right after selection
86
+ if (justSelectedRef.current && !isNaturalFocus) {
87
+ justSelectedRef.current = false;
88
+ return;
89
+ }
90
+
91
+ // Open dropdown for natural focus
92
+ if (isNaturalFocus) {
93
+ setIsOpen(true);
94
+ justSelectedRef.current = false;
95
+ }
76
96
  };
77
97
 
98
+ useEffect(() => {
99
+ if (!inputRef || typeof inputRef === "function") return;
100
+ const inputElement = inputRef.current;
101
+ if (!inputElement) return;
102
+
103
+ const handleMouseDown = () => {
104
+ // Track that user clicked/interacted with input
105
+ userInteractedRef.current = true;
106
+ };
107
+
108
+ inputElement.addEventListener("mousedown", handleMouseDown);
109
+ return () => {
110
+ inputElement.removeEventListener("mousedown", handleMouseDown);
111
+ };
112
+ }, [inputRef]);
113
+
78
114
  useOnUnmount(() => {
79
115
  if (blurTimeoutRef.current) {
80
116
  clearTimeout(blurTimeoutRef.current);
@@ -84,7 +120,12 @@ const ComboboxInner = forwardRef(
84
120
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
85
121
  const newValue = e.target.value;
86
122
  setInputValue(newValue);
123
+ justSelectedRef.current = false;
87
124
  setIsChanging(true);
125
+ // Only open immediately for sync options; async options controlled by useEffect
126
+ if (!asyncOptions) {
127
+ setIsOpen(true);
128
+ }
88
129
  clearHighlight();
89
130
  onChange?.(e);
90
131
  };
@@ -107,6 +148,7 @@ const ComboboxInner = forwardRef(
107
148
 
108
149
  // Prevent re-fetching options after selecting an option
109
150
  skipNextDebounceRef.current = true;
151
+ justSelectedRef.current = true;
110
152
 
111
153
  setIsChanging(false);
112
154
  setIsOpen(false);
@@ -147,13 +189,18 @@ const ComboboxInner = forwardRef(
147
189
  };
148
190
 
149
191
  const handleBlur = (e: FocusEvent<Element, Element>) => {
150
- // Force selection if user has matched an entry
151
- const typedText = inputValue.trim().toLowerCase();
152
- const highlightedOption = getHighlightedOption();
153
- const label = getOptionLabel(highlightedOption);
192
+ // Force selection if user has matched an entry by typing (not already selected)
193
+ // Defer this to allow natural focus flow to complete first
194
+ if (!justSelectedRef.current) {
195
+ const typedText = inputValue.trim().toLowerCase();
196
+ const highlightedOption = getHighlightedOption();
197
+ const label = getOptionLabel(highlightedOption);
154
198
 
155
- if (typedText === label?.toLowerCase()) {
156
- handleOptionSelect(highlightedOption as T);
199
+ if (typedText === label?.toLowerCase()) {
200
+ setTimeout(() => {
201
+ handleOptionSelect(highlightedOption as T);
202
+ }, 0);
203
+ }
157
204
  }
158
205
 
159
206
  blurTimeoutRef.current = setTimeout(() => {
@@ -166,21 +213,25 @@ const ComboboxInner = forwardRef(
166
213
  switch (e.key) {
167
214
  case "ArrowDown":
168
215
  e.preventDefault();
216
+ justSelectedRef.current = false;
169
217
  setIsOpen(true);
170
218
  highlightNextOption();
171
219
  break;
172
220
  case "ArrowUp":
173
221
  e.preventDefault();
222
+ justSelectedRef.current = false;
174
223
  setIsOpen(true);
175
224
  highlightPreviousOption();
176
225
  break;
177
226
  case "Home":
178
227
  e.preventDefault();
228
+ justSelectedRef.current = false;
179
229
  setIsOpen(true);
180
230
  highlightFirstOption();
181
231
  break;
182
232
  case "End":
183
233
  e.preventDefault();
234
+ justSelectedRef.current = false;
184
235
  setIsOpen(true);
185
236
  highlightLastOption();
186
237
  break;
@@ -227,19 +278,30 @@ const ComboboxInner = forwardRef(
227
278
  className,
228
279
  );
229
280
 
281
+ const getStatusMessage = () => {
282
+ if (isLoading) return "Loading options";
283
+ if (!filteredOptions || filteredOptions.length === 0) {
284
+ return isChanging ? "No options found" : "";
285
+ }
286
+ const count = isOptionGroup(filteredOptions)
287
+ ? filteredOptions.reduce((sum, group) => sum + group.options.length, 0)
288
+ : filteredOptions.length;
289
+ return isOpen && isChanging
290
+ ? `${count} option${count === 1 ? "" : "s"} available`
291
+ : "";
292
+ };
293
+
230
294
  return (
231
295
  <div id={id} data-testid="mobius-combobox__wrapper" className={classes}>
232
- {isLoading && (
233
- <VisuallyHidden
234
- role="status"
235
- aria-live="polite"
236
- id={statusId}
237
- elementType="div"
238
- className="mobius-combobox__status"
239
- >
240
- Loading options
241
- </VisuallyHidden>
242
- )}
296
+ <VisuallyHidden
297
+ role="status"
298
+ aria-live="polite"
299
+ id={statusId}
300
+ elementType="div"
301
+ className="mobius-combobox__status"
302
+ >
303
+ {getStatusMessage()}
304
+ </VisuallyHidden>
243
305
  <TextField
244
306
  {...otherProps}
245
307
  className="mobius-combobox__input"
@@ -254,6 +316,7 @@ const ComboboxInner = forwardRef(
254
316
  aria-describedby={isLoading ? statusId : undefined}
255
317
  aria-autocomplete="list"
256
318
  aria-haspopup="listbox"
319
+ aria-owns={listboxId}
257
320
  aria-controls={listboxId}
258
321
  aria-expanded={isOpen}
259
322
  aria-activedescendant={
@@ -1,4 +1,6 @@
1
1
  import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
2
4
  import { TextOrHTML } from ".";
3
5
 
4
6
  const CLASS_NAME = "mobius-text";
@@ -124,4 +126,61 @@ describe("TextOrHTML", () => {
124
126
  expect(screen.getByTestId("test")).toHaveClass("--has-line-height-tight");
125
127
  });
126
128
  });
129
+
130
+ // See: https://github.com/facebook/react/issues/31660
131
+ describe("React 19 dangerouslySetInnerHTML regression", () => {
132
+ it("should not replace innerHTML on re-render when text prop hasn't changed", async () => {
133
+ const user = userEvent.setup();
134
+ const TestWrapper = () => {
135
+ const [, setCounter] = useState(0);
136
+ return (
137
+ <>
138
+ <TextOrHTML text="<strong data-testid='content'>Text</strong>" />
139
+ <button onClick={() => setCounter(c => c + 1)} data-testid="btn">
140
+ Re-render
141
+ </button>
142
+ </>
143
+ );
144
+ };
145
+
146
+ render(<TestWrapper />);
147
+
148
+ const element = screen.getByTestId("content");
149
+ element.setAttribute("data-marker", "test");
150
+
151
+ await user.click(screen.getByTestId("btn"));
152
+
153
+ // If innerHTML was unnecessarily replaced, the marker would be lost
154
+ expect(screen.getByTestId("content")).toHaveAttribute(
155
+ "data-marker",
156
+ "test",
157
+ );
158
+ });
159
+
160
+ it("should update innerHTML when text prop changes", async () => {
161
+ const user = userEvent.setup();
162
+ const TestWrapper = () => {
163
+ const [text, setText] = useState(
164
+ "<span data-testid='content'>A</span>",
165
+ );
166
+ return (
167
+ <>
168
+ <TextOrHTML text={text} />
169
+ <button
170
+ onClick={() => setText("<span data-testid='content'>B</span>")}
171
+ data-testid="btn"
172
+ >
173
+ Update
174
+ </button>
175
+ </>
176
+ );
177
+ };
178
+
179
+ render(<TestWrapper />);
180
+ expect(screen.getByTestId("content")).toHaveTextContent("A");
181
+
182
+ await user.click(screen.getByTestId("btn"));
183
+ expect(screen.getByTestId("content")).toHaveTextContent("B");
184
+ });
185
+ });
127
186
  });
@@ -1,5 +1,5 @@
1
1
  import type { Ref } from "react";
2
- import { forwardRef } from "react";
2
+ import { forwardRef, useMemo } from "react";
3
3
  import type { ForwardedRefComponent } from "../../types/components";
4
4
  import type { TextElementType, TextProps } from "../Text/Text";
5
5
  import { Text } from "../Text/Text";
@@ -33,10 +33,14 @@ const TextOrHTML: ForwardedRefComponent<TextOrHTMLProps, TextElementType> =
33
33
  ) => {
34
34
  const DangerousComponent = htmlElementType;
35
35
 
36
+ // Memoize the dangerouslySetInnerHTML object to prevent unnecessary re-renders
37
+ // See: https://github.com/facebook/react/issues/31660
38
+ const dangerousHTML = useMemo(() => ({ __html: text }), [text]);
39
+
36
40
  const dangerousElement = (
37
41
  <DangerousComponent
38
42
  className={htmlClassName}
39
- dangerouslySetInnerHTML={{ __html: text }}
43
+ dangerouslySetInnerHTML={dangerousHTML}
40
44
  />
41
45
  );
42
46