fix(desktop): disable spellcheck/autocorrect/autocapitalize on emoji picker search (#1438)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-01 16:23:55 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent b1d9d955de
commit bdeab23b59
4 changed files with 213 additions and 21 deletions
@@ -28,6 +28,58 @@ function warmEmojiIndex() {
}
warmEmojiIndex();
/**
* Reach into the `em-emoji-picker` shadow root and disable spellcheck,
* autocorrect, and autocapitalize on its search input. When `autoFocus` is
* true, also focus the input so the cursor lands there immediately — this
* makes focus deterministic and owned by us, preventing Radix's focus-scope
* from racing emoji-mart's own async focus call and winning.
*
* emoji-mart mounts the custom element and its shadow content asynchronously,
* so the input is not present on first render. A MutationObserver on the
* shadow root watches for the input to appear, applies the attributes once,
* then disconnects. Returns a cleanup function for `useEffect`.
*/
function disableSearchInputCorrections(
host: HTMLElement,
autoFocus: boolean,
): () => void {
const picker = host.querySelector("em-emoji-picker");
const shadowRoot = picker?.shadowRoot ?? null;
if (!shadowRoot) {
return () => undefined;
}
// shadowRoot is ShadowRoot here; capture as a typed const so the nested
// closure doesn't re-widen to ShadowRoot | null.
const root: ShadowRoot = shadowRoot;
function applyAttributes() {
const input = root.querySelector<HTMLInputElement>('input[type="search"]');
if (!input) return false;
input.spellcheck = false;
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
if (autoFocus) {
input.focus();
}
return true;
}
// Fast path: input already present (e.g. picker re-mount from hot cache).
if (applyAttributes()) {
return () => undefined;
}
// Slow path: wait for the shadow subtree to include the input.
const observer = new MutationObserver(() => {
if (applyAttributes()) {
observer.disconnect();
}
});
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}
/**
* The one emoji picker for the whole app. Every place that lets a user choose
* an emoji — composing a message, reacting to a regular or system message,
@@ -64,27 +116,35 @@ export const EmojiPicker = React.memo(function EmojiPicker({
() => buildCustomEmojiCategory(customEmoji),
[customEmoji],
);
const hostRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!hostRef.current) return;
return disableSearchInputCorrections(hostRef.current, autoFocus);
}, [autoFocus]);
return (
<Picker
autoFocus={autoFocus}
custom={custom}
data={data}
maxFrequentRows={2}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Standard emoji carry a `native` glyph. Custom emoji don't — emit
// their `:shortcode:` (emoji-mart `id` == shortcode) instead. Ignore a
// malformed selection that has neither.
const value = emoji.native ?? (emoji.id ? `:${emoji.id}:` : "");
if (value) {
onSelect(value);
}
}}
perLine={8}
previewPosition="bottom"
set="native"
skinTonePosition="search"
theme="auto"
/>
<div ref={hostRef}>
<Picker
autoFocus={autoFocus}
custom={custom}
data={data}
maxFrequentRows={2}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Standard emoji carry a `native` glyph. Custom emoji don't — emit
// their `:shortcode:` (emoji-mart `id` == shortcode) instead. Ignore a
// malformed selection that has neither.
const value = emoji.native ?? (emoji.id ? `:${emoji.id}:` : "");
if (value) {
onSelect(value);
}
}}
perLine={8}
previewPosition="bottom"
set="native"
skinTonePosition="search"
theme="auto"
/>
</div>
);
});
@@ -8,6 +8,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type ComposerEmojiPickerProps = {
disabled?: boolean;
/** Called when the popover closes without an emoji selection (Escape,
* click-outside). Use this to restore focus to the editor. */
onClose?: () => void;
onEmojiSelect: (emoji: string) => void;
onOpenChange: (open: boolean) => void;
onTriggerMouseDown: () => void;
@@ -16,6 +19,7 @@ type ComposerEmojiPickerProps = {
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
disabled = false,
onClose,
onEmojiSelect,
onOpenChange,
onTriggerMouseDown,
@@ -44,10 +48,23 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
<PopoverContent
align="start"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
// Prevent Radix's FocusScope from stealing focus on open — our
// disableSearchInputCorrections MutationObserver owns focus for
// the shadow-DOM search input (autoFocus path).
onOpenAutoFocus={(e) => e.preventDefault()}
// Suppress Radix's default trigger-return on close. On the
// emoji-select path, insertEmoji already called editor.chain().focus()
// before the popover closes, so the editor owns focus — let it stand.
// On Escape/click-outside, onClose() restores editor focus explicitly
// so the user can keep typing without an extra click.
onCloseAutoFocus={(e) => {
e.preventDefault();
onClose?.();
}}
side="top"
sideOffset={10}
>
<EmojiPicker onSelect={onEmojiSelect} />
<EmojiPicker autoFocus onSelect={onEmojiSelect} />
</PopoverContent>
</Popover>
);
@@ -238,6 +238,7 @@ export const MessageComposerToolbar = React.memo(
</Tooltip>
<ComposerEmojiPicker
disabled={composerDisabled}
onClose={() => editor?.commands.focus()}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
+114
View File
@@ -432,3 +432,117 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => {
.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
});
test("emoji picker search input has spellcheck, autocorrect, and autocapitalize disabled", async ({
page,
}) => {
await openGeneral(page);
// Open the reaction picker on the seeded reactable message.
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
// Wait for the picker to be visible, then read the shadow-root input attributes.
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
const attrs = await picker.evaluate((el: Element) => {
const input = (
el as HTMLElement & { shadowRoot: ShadowRoot }
).shadowRoot?.querySelector<HTMLInputElement>('input[type="search"]');
if (!input) throw new Error("search input not found in shadow root");
return {
spellcheck: input.spellcheck,
autocorrect: input.getAttribute("autocorrect"),
autocapitalize: input.getAttribute("autocapitalize"),
};
});
expect(attrs.spellcheck).toBe(false);
expect(attrs.autocorrect).toBe("off");
expect(attrs.autocapitalize).toBe("off");
});
// Regression guard for PR #1438: after the spellcheck fix, the shadow-DOM
// traversal must also own autofocus so Radix's focus-scope can't steal it.
// Will reported that opening the picker required a manual click before typing.
test("emoji picker search input is focused immediately on open (no manual click needed)", async ({
page,
}) => {
await openGeneral(page);
// Open the reaction picker — it passes autoFocus, so the input must receive
// focus via our shadow traversal before the user interacts.
const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// The search input must be the active element inside the shadow root.
// If Radix's focus-scope wins instead, shadowRoot.activeElement is the
// popover host (or null), not the input — this assertion catches that.
const isFocused = await picker.evaluate((el: Element) => {
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (!input) return false;
return host.shadowRoot?.activeElement === input;
});
expect(isFocused).toBe(true);
});
test("composer emoji picker search input is focused immediately on open", async ({
page,
}) => {
await openGeneral(page);
// Open the composer emoji picker via the toolbar button.
await page.getByTestId("composer-emoji-button").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// The search input must be the active element inside the shadow root so
// the user can type immediately without a manual click.
const isFocused = await picker.evaluate((el: Element) => {
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (!input) return false;
return host.shadowRoot?.activeElement === input;
});
expect(isFocused).toBe(true);
// Pick an emoji and verify focus returns to the composer message input,
// not left stranded in the now-closed popover. The insertEmoji handler
// calls editor.chain().focus() before setIsEmojiPickerOpen(false), so
// the Tiptap editor owns focus before Radix's onCloseAutoFocus fires.
await picker.getByRole("button", { name: "😀" }).first().click();
await expect(page.getByTestId("message-input")).toBeFocused();
});
test("composer emoji picker restores editor focus on Escape dismiss", async ({
page,
}) => {
await openGeneral(page);
// Open the composer emoji picker via the toolbar button.
await page.getByTestId("composer-emoji-button").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
// Dismiss via Escape without selecting an emoji. The onClose callback
// in ComposerEmojiPicker must restore focus to the editor so the user
// can keep typing without an extra click.
await page.keyboard.press("Escape");
await expect(page.getByTestId("message-input")).toBeFocused();
});