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
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def card(
class_name="flex flex-col gap-2 p-8",
),
content,
rx.el.a(href=href, class_name="absolute inset-0"),
rx.el.a(href=href, aria_label=title, class_name="absolute inset-0"),
class_name="flex flex-col bg-secondary-1/96 backdrop-blur-[16px] rounded-xl relative cursor-pointer transition-colors overflow-hidden shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_12px_24px_0_rgba(0,0,0,0.08),0_1px_1px_0_rgba(0,0,0,0.01),0_4px_8px_0_rgba(0,0,0,0.03)] dark:shadow-none dark:border dark:border-secondary-4",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def docs_item(
description,
class_name="text-secondary-11 text-sm font-[475]",
),
rx.el.a(to=href, class_name="absolute inset-0"),
rx.el.a(to=href, aria_label=title, class_name="absolute inset-0"),
class_name="flex flex-col gap-2 py-8 pr-8 relative group lg:max-w-[21rem] w-full max-lg:text-start hover:bg-[linear-gradient(243deg,var(--secondary-2)_0%,var(--secondary-1)_100%)]",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def link_item(
description,
class_name="text-secondary-11 text-sm font-[475] text-start",
),
rx.el.a(to=href, class_name="absolute inset-0"),
rx.el.a(to=href, aria_label=title, class_name="absolute inset-0"),
class_name=ui.cn(
"flex flex-col gap-2 pr-8 py-8 group border-r border-b border-secondary-4 relative max-lg:p-6 hover:bg-[linear-gradient(243deg,var(--secondary-2)_0%,var(--secondary-1)_100%)]",
"lg:pl-8 pl-6" if has_padding_left else "",
Expand Down
20 changes: 13 additions & 7 deletions docs/app/reflex_docs/templates/docpage/docpage.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,22 +234,26 @@ def feedback_content() -> rx.Component:
def feedback_button() -> rx.Component:
thumb_cn = "w-full gap-2 px-3 py-0.5 flex flex-row items-center justify-center whitespace-nowrap border cursor-pointer transition-colors font-small"
return ui.popover.root(
ui.popover.trigger(
render_=rx.el.div(
feedback_choice_button(
rx.el.div(
ui.popover.trigger(
render_=feedback_choice_button(
"Yes",
"ThumbsUpIcon",
1,
ui.cn("rounded-[20px_0_0_20px] border-r-0", thumb_cn),
),
feedback_choice_button(
),
ui.popover.trigger(
render_=feedback_choice_button(
"No",
"ThumbsDownIcon",
0,
ui.cn("rounded-[0_20px_20px_0]", thumb_cn),
),
class_name="w-full lg:w-auto items-center flex flex-row",
),
role="group",
aria_label="Was this page helpful?",
class_name="w-full lg:w-auto items-center flex flex-row",
),
Comment thread
FarhanAliRaza marked this conversation as resolved.
ui.popover.portal(
ui.popover.positioner(
Expand Down Expand Up @@ -746,8 +750,10 @@ def breadcrumb(path: str, nav_sidebar: rx.Component, doc_content: str | None = N
return rx.box(
docs_sidebar_drawer(
nav_sidebar,
trigger=rx.box(
class_name="absolute inset-0 bg-transparent z-[1] lg:hidden flex",
trigger=rx.el.button(
type="button",
aria_label="Open docs navigation",
class_name="absolute inset-0 bg-transparent z-[1] lg:hidden flex border-none p-0 cursor-pointer",
),
),
rx.box(
Expand Down
170 changes: 162 additions & 8 deletions docs/app/reflex_docs/views/inkeep.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,147 @@
"""UI and logic inkeep chat component."""

import reflex as rx
from reflex.constants import MemoizationMode
from reflex.event import EventSpec
from reflex.experimental.client_state import ClientStateVar
from reflex.utils.imports import ImportVar
from reflex.vars import Var
from reflex.vars.base import VarData


class InkeepSearchBar(rx.NoSSRComponent):
tag = "InkeepSearchBar"
library = "@inkeep/cxkit-react@0.5.115"


_INKEEP_LOADED_STATE = "inkeep_loaded"
_INKEEP_OPEN_STATE = "inkeep_open"
_SEARCH_WRAPPER_ID = "inkeep-search-wrapper"
_SEARCH_TRIGGER_ID = "inkeep-search-trigger"
_SEARCH_TRIGGER_FALLBACK_STYLE = (
f"#{_SEARCH_WRAPPER_ID}:has(> [id^='inkeep-shadow']) "
f"> #{_SEARCH_TRIGGER_ID} {{ display: none; }}"
)

# Inline copy of assets/icons/search.svg so the placeholder needs no extra request.
_SEARCH_ICON_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"'
' viewBox="0 0 16 16" aria-hidden="true"><path stroke="currentColor"'
' stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"'
' d="M11.332 11.333 13.999 14"/><path stroke="currentColor"'
' stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"'
' d="M12.667 7.333A5.333 5.333 0 1 0 2 7.333a5.333 5.333 0 0 0 10.667 0"/></svg>'
)

# Runs on mount (as a real effect, not an inert rendered <script>) to:
# 1. Fix the placeholder's keyboard hint for non-Mac platforms, which can't
# be known during SSR (the default label is the Mac glyph).
# 2. Forward Cmd/Ctrl+K to the placeholder trigger before the widget loads,
# mounting the real widget with its modal open. Once Inkeep's shadow host
# mounts, this no-ops and Inkeep's own handler takes over.
_HOTKEY_AND_HINT_HOOK = f"""
useEffect(() => {{
const triggerId = "{_SEARCH_TRIGGER_ID}";
const wrapperId = "{_SEARCH_WRAPPER_ID}";
const platform =
(navigator.userAgentData && navigator.userAgentData.platform) ||
navigator.platform ||
navigator.userAgent ||
"";
if (!/mac|iphone|ipad|ipod/i.test(platform)) {{
const hint = document.getElementById(triggerId)?.querySelector("kbd > span");
if (hint) hint.textContent = "Ctrl";
}}
const onKeydown = (event) => {{
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return;
const trigger = document.getElementById(triggerId);
const widgetReady = document
.getElementById(wrapperId)
?.querySelector("[id^='inkeep-shadow']");
if (!trigger || widgetReady) return;
event.preventDefault();
trigger.click();
}};
document.addEventListener("keydown", onKeydown);
return () => document.removeEventListener("keydown", onKeydown);
}}, [])
"""


def _controlled_modal_settings(opened: ClientStateVar) -> Var:
"""Create controlled modal settings for the Inkeep search bar.

Args:
opened: Client state var tracking whether the modal is open.

Returns:
A Var containing the Inkeep props and client state dependencies.
"""
is_open = opened.value
on_open_change = opened.set
return Var(
"{...searchBarProps, modalSettings: "
f"{{isOpen: {is_open!s}, onOpenChange: {on_open_change!s}}}}}"
)._replace(
merge_var_data=VarData.merge(
is_open._get_all_var_data(),
on_open_change._get_all_var_data(),
)
)


def _search_trigger_placeholder(on_click: EventSpec) -> rx.Component:
"""Lightweight stand-in for the Inkeep search bar button.

Mirrors the widget's trigger styling so mounting the real search bar does
not shift layout, while keeping the ~276 KB Inkeep chunk (and the Google
Fonts stylesheet it injects) off the critical path until first use.

Args:
on_click: Event handler that mounts the real search widget.

Returns:
The placeholder button component.
"""
return rx.el.button(
rx.html(_SEARCH_ICON_SVG, class_name="flex shrink-0"),
rx.el.span("Search", class_name="max-xl:hidden"),
rx.el.kbd(
# Two keys with a 2px gap, mirroring Inkeep's kbd-wrapper markup.
rx.el.span("⌘"),
rx.el.span("K"),
class_name="max-xl:hidden ml-auto flex items-center justify-center gap-0.5 px-1 h-5 rounded bg-secondary-3 text-[0.8125rem] font-[475] leading-5 font-sans",
),
id=_SEARCH_TRIGGER_ID,
type="button",
aria_label="Search documentation",
on_click=on_click,
class_name=(
"flex flex-row items-center justify-center xl:justify-start gap-2 rounded-lg "
"h-8 min-h-8 w-8 xl:w-40 px-0 xl:px-2 "
"bg-secondary-1 dark:bg-secondary-2 hover:bg-secondary-2 dark:hover:bg-secondary-3 "
"text-secondary-11 text-sm font-medium leading-6 border-none cursor-pointer "
"shadow-[0_-1px_0_0_rgba(0,0,0,0.08)_inset,0_0_0_1px_rgba(0,0,0,0.08)_inset,0_1px_2px_0_rgba(0,0,0,0.02),0_1px_4px_0_rgba(0,0,0,0.02)] "
"dark:shadow-[0_-1px_0_0_rgba(255,255,255,0.06)_inset,0_0_0_1px_rgba(255,255,255,0.04)_inset]"
),
Comment on lines +119 to +126

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the text color in the placeholder doesn't quite match the text color after the widget loads

before load

Image

after load

Image

There's also a little blip between the hiding of the placeholder and the showing of the actual widget that would be nice to eliminate if possible

Screen.Recording.2026-07-09.at.3.40.59.PM.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

)


class Search(rx.el.Div):
_memoization_mode = MemoizationMode(recursive=False)

def add_imports(self):
"""Add the imports for the component."""
return {
"react": {ImportVar(tag="useContext")},
"react": {ImportVar(tag="useContext"), ImportVar(tag="useEffect")},
"$/utils/context": {ImportVar(tag="ColorModeContext")},
}

def add_hooks(self):
"""Add the hooks for the component."""
return [
"const { resolvedColorMode } = useContext(ColorModeContext)",
_HOTKEY_AND_HINT_HOOK,
"""
const escalationParams = {
type: "object",
Expand Down Expand Up @@ -135,6 +255,9 @@ def add_hooks(self):
forcedColorMode: resolvedColorMode, // options: 'light' or dark'
},
theme: {
// The site ships its own fonts; skip the widget's default Google Fonts
// (Inter) stylesheet injection.
disableLoadingDefaultFont: true,
// Add inline styles using the recommended approach from the docs
styles: [
{
Expand Down Expand Up @@ -280,7 +403,8 @@ def add_hooks(self):
width: auto;
}

.ikp-search-bar__kbd-wrapper {
[data-theme='light'] .ikp-search-bar__kbd-wrapper,
[data-theme='dark'] .ikp-search-bar__kbd-wrapper {
padding: 0px 0.25rem;
justify-content: center;
align-items: center;
Expand All @@ -299,8 +423,17 @@ def add_hooks(self):
width: fit-content;
}

.ikp-search-bar__text,
.ikp-search-bar__icon {
.ikp-search-bar__kbd-wrapper kbd {
/* Inkeep's base styles give the inner keys a monospace stack;
inherit the wrapper's font so the hint matches the
pre-mount placeholder trigger. */
font: inherit;
}

[data-theme='light'] .ikp-search-bar__text,
[data-theme='dark'] .ikp-search-bar__text,
[data-theme='light'] .ikp-search-bar__icon,
[data-theme='dark'] .ikp-search-bar__icon {
color: var(--secondary-11);
font-weight: 500;
font-style: normal;
Expand Down Expand Up @@ -390,11 +523,32 @@ def add_hooks(self):

@classmethod
def create(cls):
"""Create the search component."""
"""Create the search component.

The Inkeep widget is not mounted until the user interacts with the
search trigger, keeping its large bundle out of the initial page load.
The modal is controlled and starts open, so the interaction that
mounts the widget also opens search.
"""
loaded = ClientStateVar.create(
_INKEEP_LOADED_STATE, default=False, global_ref=False
)
opened = ClientStateVar.create(
_INKEEP_OPEN_STATE, default=True, global_ref=False
)
return super().create(
InkeepSearchBar.create(
special_props=[Var("{...searchBarProps}")],
)
rx.el.style(_SEARCH_TRIGGER_FALLBACK_STYLE),
rx.cond(
loaded.value,
InkeepSearchBar.create(
special_props=[_controlled_modal_settings(opened)],
),
rx.fragment(),
),
_search_trigger_placeholder(
on_click=rx.call_function(loaded.set_value(True)),
),
id=_SEARCH_WRAPPER_ID,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.


Expand Down
82 changes: 82 additions & 0 deletions docs/app/tests/test_inkeep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for the lazy-loaded Inkeep search control."""

import re

from reflex_docs.views.inkeep import _HOTKEY_AND_HINT_HOOK, Search


def _has_prop(component: dict, prop: str) -> bool:
"""Check whether a rendered component has an exact prop.

Args:
component: Rendered Reflex component dictionary.
prop: Serialized prop to find.

Returns:
Whether the component contains the prop.
"""
return prop in component.get("props", [])


def test_search_keeps_local_state_in_one_memo_boundary():
"""Keep the local state hooks and their consumers in the same JS component."""
assert not Search.create()._memoization_mode.recursive


def test_search_placeholder_stays_mounted_until_shadow_host_exists():
"""Keep the placeholder visible until Inkeep's shadow host mounts."""
search = Search.create()
rendered = search.render()
placeholder = next(
(
child
for child in rendered["children"]
if child.get("name") == '"button"'
and _has_prop(child, 'id:"inkeep-search-trigger"')
),
None,
)

assert placeholder is not None
global_css = "\n".join(
nested["contents"]
for child in rendered["children"]
if child.get("name") == '"style"'
for nested in child.get("children", [])
)
assert (
"#inkeep-search-wrapper:has(> [id^='inkeep-shadow']) > #inkeep-search-trigger"
) in global_css
assert "[id^='inkeep-shadow']" in _HOTKEY_AND_HINT_HOOK
assert ".ikp-search-bar__container" not in _HOTKEY_AND_HINT_HOOK


def test_search_widget_kbd_hint_matches_placeholder():
"""Keep the widget's kbd hint visually identical to the placeholder's.

Inkeep's base styles give the inner <kbd> keys a monospace font stack, so
the hint visibly changes font (and width) when the real widget replaces
the placeholder unless the keys inherit the wrapper's font.
"""
styles = "\n".join(Search.create().add_hooks())
assert re.search(
r"\.ikp-search-bar__kbd-wrapper kbd\s*\{[^{}]*font:\s*inherit", styles
)
# The hook targets the placeholder's first key span to swap ⌘ for Ctrl.
assert 'querySelector("kbd > span")' in _HOTKEY_AND_HINT_HOOK


def test_search_widget_dark_colors_match_placeholder():
"""Override Inkeep's higher-specificity dark colors with the placeholder color."""
styles = "\n".join(Search.create().add_hooks())

for selector in (
".ikp-search-bar__text",
".ikp-search-bar__icon",
".ikp-search-bar__kbd-wrapper",
):
assert re.search(
rf"\[data-theme='dark'\] {re.escape(selector)}[^{{}}]*"
r"\{[^{}]*color:\s*var\(--secondary-11\)",
styles,
)
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ def create(
if copy_button is not None
else Button.create(
Icon.create(tag="copy"),
aria_label="Copy code",
on_click=set_clipboard(code),
style=Style({"position": "absolute", "top": "0.5em", "right": "0"}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ def create(
if copy_button is not None
else Button.create(
Icon.create(tag="copy", size=16, color=color("gray", 11)),
aria_label="Copy code",
on_click=[
set_clipboard(cls._strip_transformer_triggers(code)),
copy_script(),
Expand Down
Loading
Loading