-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(docs): improve a11y and lazy-load Inkeep search widget #6723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
db24551
b377236
18f162c
c687125
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
after load
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
|
@@ -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: [ | ||
| { | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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, | ||
| ) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
|
|
||
|
|
||
| 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, | ||
| ) |


Uh oh!
There was an error while loading. Please reload this page.