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
12 changes: 8 additions & 4 deletions src/components/Layout/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('Header', () => {
expect(searchBar).not.toBeInTheDocument();
});

it('does not render Ask AI button and chat bar when inkeepChatEnabled is false', () => {
it('always renders the Ask AI button, but not the chat instance, when inkeepChatEnabled is false', () => {
(useStaticQuery as jest.Mock).mockReturnValue({
site: {
siteMetadata: {
Expand All @@ -157,12 +157,13 @@ describe('Header', () => {

render(<Header />);

expect(screen.queryByText('Ask AI')).not.toBeInTheDocument();
// The Ask AI button always renders; only the Inkeep chat instance is flag-gated.
expect(screen.getByText('Ask AI')).toBeInTheDocument();
const chatBar = document.getElementById('inkeep-ai-chat');
expect(chatBar).not.toBeInTheDocument();
});

it('does not render search bar or Ask AI button when both flags are false', () => {
it('always renders the search trigger and Ask AI button, but not the Inkeep instances, when both flags are false', () => {
(useStaticQuery as jest.Mock).mockReturnValue({
site: {
siteMetadata: {
Expand All @@ -176,7 +177,10 @@ describe('Header', () => {

render(<Header />);

expect(screen.queryByText('Ask AI')).not.toBeInTheDocument();
// Our own search trigger and the Ask AI button are always present; the Inkeep
// search/chat instances only mount when their flags are enabled.
expect(screen.getByText('Search')).toBeInTheDocument();
expect(screen.getByText('Ask AI')).toBeInTheDocument();
Comment on lines +182 to +183

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These only assert the buttons render, not that clicking them opens anything — so if the shadow-DOM open path breaks, the tests stay green. A click-wiring test (mock querySelector, assert the trigger fires) would catch that. The real shadow DOM isn't jsdom-friendly so it's partial at best, but the click path is the risky bit worth pinning down.

~ 𝒞𝓁𝒶𝓊𝒹𝑒

const searchBar = document.getElementById('inkeep-search');
const chatBar = document.getElementById('inkeep-ai-chat');
expect(searchBar).not.toBeInTheDocument();
Expand Down
111 changes: 60 additions & 51 deletions src/components/Layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,46 @@ const activeHeaderLinkClassName = 'text-neutral-1300 dark:text-neutral-000 bg-or
const inactiveHeaderLinkClassName =
'text-neutral-900 dark:text-neutral-500 hover:text-neutral-1300 dark:hover:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200';

// Opens the Inkeep search modal by reaching into its shadow DOM. The Inkeep search
// instance stays mounted but hidden (see #inkeep-search-holder), so this is our own
// trigger for it. No-ops where the widget isn't loaded (e.g. local dev).
const openInkeepSearch = () => {
track('docs_search_button_clicked');
const trigger = document.querySelector('#inkeep-search > div')?.shadowRoot?.querySelector('button');
trigger?.click();
};

// Custom search trigger rendered in both local and production so the header bar is a
// single, design-controlled element. In production it opens the Inkeep modal; locally
// it is inert. The modal itself is unchanged.
const SearchTrigger = () => (
<button
type="button"
aria-label="Search"
onClick={openInkeepSearch}
className={cn(
'focus-base flex items-center justify-between gap-2 w-[200px] h-9 px-3 rounded-lg transition-colors',
'bg-neutral-100 dark:bg-neutral-1200 border border-neutral-400 dark:border-neutral-900',
'hover:border-neutral-600 dark:hover:border-neutral-700',
)}
>
<span className="flex items-center gap-2 text-neutral-600 dark:text-neutral-700">
<Icon name="icon-gui-magnifying-glass-outline" size="16px" />
<span className="ui-text-label4 font-normal">Search</span>
</span>
<span className="flex items-center gap-0.5">
{['⌘', 'K'].map((key) => (
<kbd
key={key}
className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded border border-neutral-300 dark:border-neutral-900 bg-neutral-000 dark:bg-neutral-1100 text-[11px] font-medium text-neutral-700 dark:text-neutral-500"
>
{key}
</kbd>
))}
</span>
</button>
);

const mobileTabs = ['Platform', 'Products', 'Examples'];

const helpResourcesItems = [
Expand Down Expand Up @@ -102,8 +142,10 @@ const Header: React.FC = () => {
}
}, 150);

// Physically shift the inkeep search bar around given that it's initialised once
const targetId = isMobileMenuOpen ? 'inkeep-search-mobile-mount' : 'inkeep-search-mount';
// The Inkeep search bar is initialised once. On mobile we surface it inside the open
// menu; otherwise it lives in a hidden holder (the visible desktop trigger is our own
// SearchTrigger button, which opens this instance's modal).
const targetId = isMobileMenuOpen ? 'inkeep-search-mobile-mount' : 'inkeep-search-holder';
const targetElement = document.getElementById(targetId);
const searchBar = searchBarRef.current;

Expand Down Expand Up @@ -222,59 +264,26 @@ const Header: React.FC = () => {
)}
</div>
<div id="inkeep-search-mount" className="hidden md:flex items-center justify-end flex-1 min-w-0 ml-4 mr-2">
{!externalScriptsData.inkeepSearchEnabled && (
<div className="w-full max-w-[280px]">
<button
className={cn(
secondaryButtonClassName,
'w-full justify-between gap-2 bg-neutral-100 dark:bg-neutral-1200 hover:border-neutral-500 dark:hover:border-neutral-800 text-neutral-600 dark:text-neutral-700 font-normal',
)}
onClick={() => {
// Inkeep renders its chat widget inside a shadow DOM; this reaches in to
// programmatically open it. Will silently no-op if the widget structure changes.
const chatContainer = document.querySelector('#inkeep-ai-chat > div');
const chatButton = chatContainer?.shadowRoot?.querySelector('button');
if (chatButton) {
chatButton.click();
}
}}
>
<span className="flex items-center gap-2">
<Icon name="icon-gui-magnifying-glass-outline" size="16px" />
<span>Search docs...</span>
</span>
<span className="flex items-center gap-0.5">
<kbd className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded border border-neutral-400 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-1200 text-[11px] font-medium text-neutral-700 dark:text-neutral-500">
</kbd>
<kbd className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded border border-neutral-400 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-1200 text-[11px] font-medium text-neutral-700 dark:text-neutral-500">
K
</kbd>
</span>
</button>
</div>
)}
<SearchTrigger />
</div>
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<div className="hidden md:flex gap-2 pt-3 md:py-0 px-4 md:px-0 shrink-0">
{externalScriptsData.inkeepChatEnabled && (
<button
className={secondaryButtonClassName}
onClick={() => {
const chatContainer = document.querySelector('#inkeep-ai-chat > div');
const chatButton = chatContainer?.shadowRoot?.querySelector('button');
<button
className={secondaryButtonClassName}
onClick={() => {
const chatContainer = document.querySelector('#inkeep-ai-chat > div');
const chatButton = chatContainer?.shadowRoot?.querySelector('button');

track('docs_ask_ai_button_clicked');
track('docs_ask_ai_button_clicked');

if (chatButton) {
chatButton.click();
}
}}
>
<Icon name="icon-gui-sparkles-outline" size="20px" />
<span>Ask AI</span>
</button>
)}
if (chatButton) {
chatButton.click();
}
Comment on lines +273 to +281

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This Ask AI onClick is now a near-twin of openInkeepSearch up top — query #X > div → shadow button → track → click. Worth pulling both into one openInkeepWidget(hostSelector, trackEvent) so the shadow-DOM reach-in lives in a single place.

~ 𝒞𝓁𝒶𝓊𝒹𝑒

}}
>
<Icon name="icon-gui-sparkles-outline" size="20px" />
<span>Ask AI</span>
</button>
<DropdownMenu.Root>
<Tooltip.Root>
<DropdownMenu.Trigger asChild>
Expand Down Expand Up @@ -377,7 +386,7 @@ const Header: React.FC = () => {
</button>
</div>

<div className="hidden">
<div id="inkeep-search-holder" className="hidden">
{externalScriptsData.inkeepSearchEnabled && (
<InkeepSearchBar ref={searchBarRef} instanceType="search" extraInputStyle={{ backgroundColor: 'white' }} />
)}
Expand Down
Loading