mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
b1d9d955de
commit
bdeab23b59
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user