Skip to content

Commit 3338f0f

Browse files
committed
fix(showcase/shell-docs): search trigger avoids hydration mismatch + respects input focus
- isMac now starts as null and is only resolved in useEffect, so SSR output matches the first client render; reserve horizontal space on the shortcut pill with a non-breaking-space placeholder (+ suppressHydrationWarning) so ⌘K/Ctrl+K swap-in doesn't reflow the button - Cmd/Ctrl+K no longer hijacks the browser shortcut when focus is inside an unrelated <input>, <textarea>, <select>, or contenteditable; still toggles when focus is within the search modal itself (data-search-modal boundary) - Trigger buttons use setOpen((prev) => !prev) so Cmd+K-on-top-of-existing-modal toggles correctly and the hint reflects the true action
1 parent 3cd0aa5 commit 3338f0f

1 file changed

Lines changed: 35 additions & 6 deletions

File tree

showcase/shell-docs/src/components/search-trigger.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@
33
import { useState, useEffect } from "react";
44
import { SearchModal } from "./search-modal";
55

6+
function isEditableTarget(target: EventTarget | null): boolean {
7+
if (!target || !(target instanceof HTMLElement)) return false;
8+
const tag = target.tagName;
9+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
10+
if (target.isContentEditable) return true;
11+
return false;
12+
}
13+
614
export function SearchTrigger({
715
iconOnly = false,
816
}: { iconOnly?: boolean } = {}) {
9-
const [isMac, setIsMac] = useState(true);
17+
// Start as null so SSR output matches the initial client render; resolve
18+
// after mount to avoid hydration mismatch flashing ⌘K → Ctrl+K on non-Mac.
19+
const [isMac, setIsMac] = useState<boolean | null>(null);
1020
const [open, setOpen] = useState(false);
1121

1222
useEffect(() => {
@@ -18,6 +28,15 @@ export function SearchTrigger({
1828
useEffect(() => {
1929
function onKeyDown(e: KeyboardEvent) {
2030
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
31+
// Don't hijack Cmd/Ctrl+K when the user is typing in an unrelated
32+
// input / textarea / contenteditable — only steal the shortcut when
33+
// focus is outside an editable element or already inside our own
34+
// search modal.
35+
const target = e.target as HTMLElement | null;
36+
const insideSearchModal =
37+
target?.closest?.("[data-search-modal]") != null;
38+
if (isEditableTarget(target) && !insideSearchModal) return;
39+
2140
e.preventDefault();
2241
setOpen((prev) => !prev);
2342
}
@@ -31,7 +50,7 @@ export function SearchTrigger({
3150
return (
3251
<>
3352
<button
34-
onClick={() => setOpen(true)}
53+
onClick={() => setOpen((prev) => !prev)}
3554
className="flex items-center justify-center w-8 h-8 rounded-md text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
3655
aria-label="Search"
3756
>
@@ -57,13 +76,19 @@ export function SearchTrigger({
5776
return (
5877
<>
5978
<button
60-
onClick={() => setOpen(true)}
79+
onClick={() => setOpen((prev) => !prev)}
6180
className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-1.5 text-xs text-[var(--text-muted)] cursor-pointer hover:border-[var(--text-faint)] transition-colors min-w-[200px]"
6281
>
6382
<span></span>
6483
<span>Search docs, demos...</span>
65-
<span className="ml-auto font-mono text-[10px] border border-[var(--border)] px-1 py-0.5 rounded bg-[var(--bg-surface)]">
66-
{isMac ? "⌘K" : "Ctrl+K"}
84+
<span
85+
className="ml-auto font-mono text-[10px] border border-[var(--border)] px-1 py-0.5 rounded bg-[var(--bg-surface)]"
86+
// Reserve horizontal room so the button doesn't reflow when the
87+
// shortcut hint appears after hydration.
88+
style={{ minWidth: "3.25rem", textAlign: "center" }}
89+
suppressHydrationWarning
90+
>
91+
{isMac === null ? "\u00A0" : isMac ? "⌘K" : "Ctrl+K"}
6792
</span>
6893
</button>
6994
{open && <SearchModalWrapper onClose={() => setOpen(false)} />}
@@ -72,5 +97,9 @@ export function SearchTrigger({
7297
}
7398

7499
function SearchModalWrapper({ onClose }: { onClose: () => void }) {
75-
return <SearchModal onClose={onClose} />;
100+
return (
101+
<div data-search-modal>
102+
<SearchModal onClose={onClose} />
103+
</div>
104+
);
76105
}

0 commit comments

Comments
 (0)