Skip to content

Commit 2ae66d6

Browse files
authored
fix(ui-kit): don't hijack Cmd/Ctrl+B Bold in text fields (SidebarProvider) (#8305) (#8341)
1 parent 3f081c4 commit 2ae66d6

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { SidebarProvider, useSidebar } from "./sidebar";
4+
5+
// jsdom implements no window.matchMedia; SidebarProvider pulls it in via useIsMobile. Stub it desktop-shaped
6+
// (matches:false), the standard shadcn use-mobile test setup.
7+
beforeEach(() => {
8+
vi.stubGlobal(
9+
"matchMedia",
10+
vi.fn(() => ({
11+
matches: false,
12+
media: "",
13+
addEventListener: () => {},
14+
removeEventListener: () => {},
15+
})),
16+
);
17+
});
18+
afterEach(() => vi.unstubAllGlobals());
19+
20+
// #8305: SidebarProvider's global Cmd/Ctrl+B keydown handler used to toggle the sidebar (and preventDefault())
21+
// no matter what had focus — hijacking the browser-native Bold shortcut inside text fields. These pin the
22+
// isTyping() guard: the shortcut still works from non-editable targets, and is inert inside a form field or
23+
// contenteditable element (target's own text-editing keeps the native behavior).
24+
25+
function StateProbe() {
26+
const { open } = useSidebar();
27+
return <span data-testid="sidebar-open">{open ? "open" : "closed"}</span>;
28+
}
29+
30+
function setup() {
31+
const utils = render(
32+
<SidebarProvider>
33+
<StateProbe />
34+
<input data-testid="field-input" />
35+
<textarea data-testid="field-textarea" />
36+
<div data-testid="field-editable" />
37+
</SidebarProvider>,
38+
);
39+
// jsdom does not derive `isContentEditable` from the contentEditable attribute, so define it explicitly to
40+
// represent a real contenteditable element the way a browser reports it to the guard.
41+
const editable = screen.getByTestId("field-editable");
42+
Object.defineProperty(editable, "isContentEditable", {
43+
value: true,
44+
configurable: true,
45+
});
46+
return utils;
47+
}
48+
49+
const openState = () => screen.getByTestId("sidebar-open").textContent;
50+
51+
describe("SidebarProvider Cmd/Ctrl+B guard (#8305)", () => {
52+
it("toggles the sidebar on Cmd/Ctrl+B from a non-editable target and prevents the default", () => {
53+
setup();
54+
const before = openState();
55+
// fireEvent returns false when the event's default was prevented.
56+
const notPrevented = fireEvent.keyDown(document.body, {
57+
key: "b",
58+
ctrlKey: true,
59+
});
60+
expect(notPrevented).toBe(false); // preventDefault() ran
61+
expect(openState()).not.toBe(before); // state flipped
62+
});
63+
64+
it("does not toggle and does not preventDefault when the target is an input/textarea/contenteditable", () => {
65+
setup();
66+
for (const id of ["field-input", "field-textarea", "field-editable"]) {
67+
const before = openState();
68+
const notPrevented = fireEvent.keyDown(screen.getByTestId(id), {
69+
key: "b",
70+
metaKey: true,
71+
});
72+
expect(notPrevented, id).toBe(true); // default NOT prevented — native Bold survives
73+
expect(openState(), id).toBe(before); // sidebar unchanged
74+
}
75+
});
76+
77+
it("leaves an unrelated Cmd/Ctrl chord (not 'b') alone", () => {
78+
setup();
79+
const before = openState();
80+
const notPrevented = fireEvent.keyDown(document.body, {
81+
key: "k",
82+
ctrlKey: true,
83+
});
84+
expect(notPrevented).toBe(true);
85+
expect(openState()).toBe(before);
86+
});
87+
});

packages/loopover-ui-kit/src/components/sidebar.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
3030
const SIDEBAR_WIDTH_ICON = "3rem";
3131
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
3232

33+
/** Whether a keyboard event originates from a text field or contenteditable element, in which case a global
34+
* shortcut handler must not act (Cmd/Ctrl+B is the browser-native Bold inside editable text). Mirrors exactly
35+
* the `isTyping` guard apps/loopover-ui's keyboard-shortcuts.tsx and app-shell.tsx already apply to their own
36+
* global keydown handlers (#8305). */
37+
function isTyping(target: EventTarget | null): boolean {
38+
if (!(target instanceof HTMLElement)) return false;
39+
const tag = target.tagName;
40+
return (
41+
tag === "INPUT" ||
42+
tag === "TEXTAREA" ||
43+
tag === "SELECT" ||
44+
target.isContentEditable
45+
);
46+
}
47+
3348
type SidebarContextProps = {
3449
state: "expanded" | "collapsed";
3550
open: boolean;
@@ -107,6 +122,10 @@ const SidebarProvider = React.forwardRef<
107122
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
108123
(event.metaKey || event.ctrlKey)
109124
) {
125+
// #8305: don't hijack the browser-native Cmd/Ctrl+B (Bold) while the user is typing in a text field
126+
// or contenteditable element — return before preventDefault()/toggle, matching the other two global
127+
// keydown handlers in this repo (keyboard-shortcuts.tsx, app-shell.tsx).
128+
if (isTyping(event.target)) return;
110129
event.preventDefault();
111130
toggleSidebar();
112131
}

0 commit comments

Comments
 (0)