@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/CHANGELOG.md +7 -0
- package/dist/cjs/index.js +57 -9
- package/dist/esm/index.js +58 -10
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/components/Combobox/Combobox.test.tsx +1 -1
- package/src/components/Combobox/Combobox.tsx +81 -18
- package/src/components/TextOrHTML/TextOrHTML.test.tsx +59 -0
- package/src/components/TextOrHTML/TextOrHTML.tsx +6 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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={
|
|
43
|
+
dangerouslySetInnerHTML={dangerousHTML}
|
|
40
44
|
/>
|
|
41
45
|
);
|
|
42
46
|
|