Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .typescript/tsbuild-esm.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- `<TextField />`, `<TextArea />`
- fix emoji false-positives in invisible character detection

### Changed

Expand Down
23 changes: 23 additions & 0 deletions src/components/TextField/stories/TextField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
83 changes: 83 additions & 0 deletions src/components/TextField/tests/useTextValidation.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<HookWrapper value={value} callback={callback} callbackDelay={callbackDelay} />);
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)", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should remove variation selectors completely from the black list. Emojis are not the only use case.

const callback = runWithValue("✔️");
expect(callback).toHaveBeenCalledWith(new Set());
});

it("does not flag ZWJ sequence emoji 👨‍👩‍👧‍👦", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with the ZWJ. There are also legitimate use cases for it besides emojis.

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());
});
});
});
25 changes: 17 additions & 8 deletions src/components/TextField/useTextValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,28 @@ export const useTextValidation = <T>({ 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"), []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the u20E3 codepoint in this regex? This does not seem to be matched by the detection regex.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

u20E3 is part of keycap emoji sequences like 1️⃣ (1 + uFE0F + u20E3). Without it, the variation selector uFE0F inside that cluster would trigger a false positive — since 1 is not Extended_Pictographic, the reg exp alone won't recognize it as an emoji to skip


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(
Expand Down