;
MockIcon.displayName = 'MockIcon';
@@ -143,7 +148,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: {
@@ -157,12 +162,13 @@ describe('Header', () => {
render();
- 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: {
@@ -176,10 +182,62 @@ describe('Header', () => {
render();
- 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();
const searchBar = document.getElementById('inkeep-search');
const chatBar = document.getElementById('inkeep-ai-chat');
expect(searchBar).not.toBeInTheDocument();
expect(chatBar).not.toBeInTheDocument();
});
+
+ // Mimics Inkeep having mounted its widget: a child `div` whose open shadow root holds
+ // the real trigger `button`. This is the exact structure the header reaches into to
+ // open the modal, so it pins down the shadow-DOM click path that jsdom can't exercise
+ // against the real widget. If the selector chain (`#host > div` → shadowRoot → button)
+ // regresses, these go red.
+ const mountInkeepTrigger = (hostId: string) => {
+ const host = document.getElementById(hostId);
+ const inner = document.createElement('div');
+ host?.appendChild(inner);
+ const triggerButton = document.createElement('button');
+ inner.attachShadow({ mode: 'open' }).appendChild(triggerButton);
+ const clickSpy = jest.fn();
+ triggerButton.addEventListener('click', clickSpy);
+ return clickSpy;
+ };
+
+ // Both Inkeep instances must be mounted for their hidden holders to exist, so explicitly
+ // enable the flags here — earlier tests leave useStaticQuery returning them disabled.
+ const enableInkeep = () =>
+ (useStaticQuery as jest.Mock).mockReturnValue({
+ site: {
+ siteMetadata: {
+ externalScriptsData: { inkeepSearchEnabled: true, inkeepChatEnabled: true },
+ },
+ },
+ });
+
+ it('opens the Inkeep search modal and tracks the click when the search trigger is clicked', () => {
+ enableInkeep();
+ render();
+ const clickSpy = mountInkeepTrigger('inkeep-search');
+
+ fireEvent.click(screen.getByText('Search'));
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(track).toHaveBeenCalledWith('docs_search_button_clicked');
+ });
+
+ it('opens the Inkeep chat modal and tracks the click when the Ask AI button is clicked', () => {
+ enableInkeep();
+ render();
+ const clickSpy = mountInkeepTrigger('inkeep-ai-chat');
+
+ fireEvent.click(screen.getByText('Ask AI'));
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(track).toHaveBeenCalledWith('docs_ask_ai_button_clicked');
+ });
});
diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx
index de7305490e..ce9a3079c3 100644
--- a/src/components/Layout/Header.tsx
+++ b/src/components/Layout/Header.tsx
@@ -42,6 +42,53 @@ 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 an Inkeep widget (search or chat) by reaching into its shadow DOM. The Inkeep
+// instances stay mounted but hidden (see #inkeep-search-holder), so these are our own
+// triggers for them. No-ops where the widget isn't loaded (e.g. local dev) or its
+// internal trigger isn't a top-level button.
+const openInkeepWidget = (hostSelector: string, eventName: string) => {
+ const trigger = document.querySelector(`${hostSelector} > div`)?.shadowRoot?.querySelector('button');
+ if (!trigger) {
+ return;
+ }
+ track(eventName);
+ trigger.click();
+};
+
+const openInkeepSearch = () => openInkeepWidget('#inkeep-search', 'docs_search_button_clicked');
+const openInkeepChat = () => openInkeepWidget('#inkeep-ai-chat', 'docs_ask_ai_button_clicked');
+
+// 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: React.FC = () => (
+
+);
+
const mobileTabs = ['Platform', 'Products', 'Examples'];
const helpResourcesItems = [
@@ -116,8 +163,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;
@@ -244,59 +293,14 @@ const Header: React.FC = () => {
)}