diff --git a/.typescript/tsbuild-esm.json b/.typescript/tsbuild-esm.json
index aa4118fed..fe99631ff 100644
--- a/.typescript/tsbuild-esm.json
+++ b/.typescript/tsbuild-esm.json
@@ -1,7 +1,7 @@
{
"extends": "./../tsconfig.json",
"compilerOptions": {
- "lib": ["dom", "dom.iterable", "es2015", "es2020", "es2021", "es2015.collection", "es2015.iterable"],
+ "lib": ["dom", "dom.iterable", "es2015", "es2020", "es2021", "es2022.intl", "es2015.collection", "es2015.iterable"],
"module": "es2015",
"target": "es5",
"noEmit": false,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce3c53c7e..70a27a289 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- use correct font sizes when `size` property is set
- `Typography`
- adjust displaying fallback symbols in different browsers
+- ``, ``
+ - fix emoji false-positives in invisible character detection
### Changed
diff --git a/src/components/TextField/stories/TextField.stories.tsx b/src/components/TextField/stories/TextField.stories.tsx
index 8a2907adb..aa2da8bef 100644
--- a/src/components/TextField/stories/TextField.stories.tsx
+++ b/src/components/TextField/stories/TextField.stories.tsx
@@ -61,3 +61,26 @@ const invisibleCharacterWarningProps: TextFieldProps = {
defaultValue: "Invisible character ->โ<-",
};
InvisibleCharacterWarning.args = invisibleCharacterWarningProps;
+
+/** Text field showing that emoji (โ๏ธ variation-selector, ๐จโ๐ฉโ๐งโ๐ฆ ZWJ, #๏ธโฃ keycap)
+ * are NOT reported as invisible characters, while a genuine ZWS still is. */
+export const InvisibleCharacterWarningWithEmoji = Template.bind({});
+
+const invisibleCharacterWarningWithEmojiProps: TextFieldProps = {
+ ...Default.args,
+ invisibleCharacterWarning: {
+ callback: (codePoints) => {
+ if (codePoints.size) {
+ const codePointsString = [...codePoints]
+ .map((n) => characters.invisibleZeroWidthCharacters.codePointMap.get(n)?.fullLabel)
+ .join(", ");
+ alert("Invisible character detected in input string. Code points: " + codePointsString);
+ }
+ },
+ callbackDelay: 500,
+ },
+ onChange: () => {},
+ // ZWS should be flagged; โ๏ธ ๐จโ๐ฉโ๐งโ๐ฆ #๏ธโฃ should NOT be flagged
+ defaultValue: "Check\u200B โ๏ธ ๐จโ๐ฉโ๐งโ๐ฆ #๏ธโฃ",
+};
+InvisibleCharacterWarningWithEmoji.args = invisibleCharacterWarningWithEmojiProps;
diff --git a/src/components/TextField/tests/useTextValidation.test.tsx b/src/components/TextField/tests/useTextValidation.test.tsx
new file mode 100644
index 000000000..253bf50a2
--- /dev/null
+++ b/src/components/TextField/tests/useTextValidation.test.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import { act, render } from "@testing-library/react";
+
+import { useTextValidation } from "../useTextValidation";
+
+const HookWrapper: React.FC<{ value: string; callback: jest.Mock; callbackDelay?: number }> = ({
+ value,
+ callback,
+ callbackDelay = 0,
+}) => {
+ useTextValidation({
+ value,
+ onChange: jest.fn(),
+ invisibleCharacterWarning: { callback, callbackDelay },
+ });
+ return null;
+};
+
+describe("useTextValidation", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ /** Render the hook with a controlled value and flush the debounce timer. */
+ const runWithValue = (value: string, callbackDelay = 0) => {
+ const callback = jest.fn();
+ render();
+ act(() => {
+ jest.runAllTimers();
+ });
+ return callback;
+ };
+
+ describe("invisible character detection", () => {
+ it("reports empty set for plain text", () => {
+ const callback = runWithValue("hello world");
+ expect(callback).toHaveBeenCalledWith(new Set());
+ });
+
+ it("detects zero-width space (U+200B)", () => {
+ const callback = runWithValue("hello\u200Bworld");
+ expect(callback).toHaveBeenCalledWith(new Set([0x200b]));
+ });
+
+ it("detects zero-width non-joiner (U+200C)", () => {
+ const callback = runWithValue("hello\u200Cworld");
+ expect(callback).toHaveBeenCalledWith(new Set([0x200c]));
+ });
+ });
+
+ describe("emoji false-positive prevention", () => {
+ it("does not flag โ๏ธ (base char + variation selector U+FE0F)", () => {
+ const callback = runWithValue("โ๏ธ");
+ expect(callback).toHaveBeenCalledWith(new Set());
+ });
+
+ it("does not flag ZWJ sequence emoji ๐จโ๐ฉโ๐งโ๐ฆ", () => {
+ const callback = runWithValue("๐จโ๐ฉโ๐งโ๐ฆ");
+ expect(callback).toHaveBeenCalledWith(new Set());
+ });
+
+ it("does not flag keycap emoji #๏ธโฃ", () => {
+ const callback = runWithValue("#๏ธโฃ");
+ expect(callback).toHaveBeenCalledWith(new Set());
+ });
+ });
+
+ describe("mixed content", () => {
+ it("detects ZWS while ignoring surrounding emoji", () => {
+ const callback = runWithValue("Check\u200B โ๏ธ๐จโ๐ฉโ๐งโ#๏ธโฃ");
+ expect(callback).toHaveBeenCalledWith(new Set([0x200b]));
+ });
+
+ it("reports empty set for text with only emoji", () => {
+ const callback = runWithValue("โ๏ธ ๐จโ๐ฉโ๐งโ๐ฆ#๏ธโฃ");
+ expect(callback).toHaveBeenCalledWith(new Set());
+ });
+ });
+});
diff --git a/src/components/TextField/useTextValidation.ts b/src/components/TextField/useTextValidation.ts
index e90450fa4..a3a0cd3e5 100644
--- a/src/components/TextField/useTextValidation.ts
+++ b/src/components/TextField/useTextValidation.ts
@@ -44,19 +44,28 @@ export const useTextValidation = ({ value, onChange, invisibleCharacterWarnin
state.current.detectedCodePoints = new Set();
}, []);
const detectionRegex = React.useMemo(() => chars.invisibleZeroWidthCharacters.createRegex(), []);
+ const segmenter = React.useMemo(() => new Intl.Segmenter(undefined, { granularity: "grapheme" }), []);
+ const emojiRegex = React.useMemo(() => new RegExp("\\p{Extended_Pictographic}|\\u20E3", "u"), []);
+
const detectIssues = React.useCallback(
(value: string): void => {
- detectionRegex.lastIndex = 0;
- let matchArray = detectionRegex.exec(value);
- while (matchArray) {
- const codePoint = matchArray[0].codePointAt(0);
- if (codePoint) {
- state.current.detectedCodePoints.add(codePoint);
+ for (const { segment } of segmenter.segment(value)) {
+ if (emojiRegex.test(segment)) {
+ // skip emoji clusters since they legitimately contain variation selectors, ZWJ, tags, etc.
+ } else {
+ detectionRegex.lastIndex = 0;
+ let matchArray = detectionRegex.exec(segment);
+ while (matchArray) {
+ const codePoint = matchArray[0].codePointAt(0);
+ if (codePoint) {
+ state.current.detectedCodePoints.add(codePoint);
+ }
+ matchArray = detectionRegex.exec(segment);
+ }
}
- matchArray = detectionRegex.exec(value);
}
},
- [detectionRegex]
+ [detectionRegex, segmenter, emojiRegex]
);
// Checks if the value contains any problematic characters with a small delay.
const checkValue = React.useCallback(