From a8bdd8a5d9592dbcffd53e21964f6783e9386b68 Mon Sep 17 00:00:00 2001 From: elvis kahoro Date: Sat, 24 Oct 2026 13:06:16 +0000 Subject: [PATCH] fix(create): make customizer sidebar and Get Code panel reachable on mobile --- native/pages/create.py | 31 +++++-- tests/test_create_page.py | 179 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 tests/test_create_page.py diff --git a/native/pages/create.py b/native/pages/create.py index df0d6435..78ce39f6 100644 --- a/native/pages/create.py +++ b/native/pages/create.py @@ -96,7 +96,7 @@ def _option_label(opt: dict) -> str: ), class_name="flex flex-col gap-2 px-1 pt-2", ), - class_name="px-3 pb-3", + class_name="min-w-[10rem] shrink-0 lg:min-w-0 lg:px-3 lg:pb-3", ) @@ -186,7 +186,14 @@ def _sidebar_desktop(): return rx.el.aside( rx.el.div( button( - "Get Code", + rx.el.span( + "Get Code", + class_name="group-data-[show=true]:hidden", + ), + rx.el.span( + "Preview", + class_name="hidden group-data-[show=true]:inline", + ), id="copy-theme-button", type="button", class_name="w-full", @@ -230,6 +237,7 @@ def _sidebar_desktop(): "__match_base__", swatch=True, ), + class_name="min-w-[10rem] shrink-0 lg:min-w-0", ), _theme_select( "radius-select", @@ -245,7 +253,11 @@ def _sidebar_desktop(): FONT_REGISTRY[0]["id"], icon=hi("TextFontIcon"), ), - class_name="flex-1 min-h-0 overflow-y-auto scrollbar-none divide-y divide-input", + class_name=( + "px-2 py-1 lg:p-0 flex gap-1 overflow-x-auto scrollbar-none " + "lg:flex-1 lg:flex-col lg:gap-0 lg:overflow-y-auto lg:overflow-x-hidden " + "lg:divide-y lg:divide-input" + ), ), rx.el.div( button( @@ -263,16 +275,16 @@ def _sidebar_desktop(): class_name="w-full flex flex-row items-center justify-center gap-1", ), open_preset(), - class_name="p-4 flex flex-col gap-3 bg-card/20", + class_name="p-3 lg:p-4 flex flex-col gap-3 bg-card/20", ), rx.el.div( reset_confirm(), class_name="p-3 bg-card/20", ), class_name=( - "hidden lg:flex w-full max-w-[12rem] shrink-0 !overflow-hidden flex-col " - "border border-input/90 divide-y divide-input h-full text-sm " - "text-card-foreground dark bg-card/90 isolate rounded-2xl" + "flex flex-col gap-3 text-sm border border-input/90 dark " + "text-card-foreground bg-card/90 isolate rounded-2xl " + "lg:w-[12rem] lg:max-w-[12rem] lg:h-full lg:gap-0 lg:divide-y lg:divide-input" ), ) @@ -345,7 +357,7 @@ def preview_space(): ), class_name="w-full h-full", ), - class_name="w-full flex-[2] min-h-0 order-first lg:order-none lg:flex-1 lg:min-w-0 lg:h-full", + class_name="w-full flex-[2] min-h-0 order-first lg:order-none lg:flex-1 lg:min-w-0 lg:h-full max-lg:group-data-[show=true]:hidden", ) @@ -375,7 +387,8 @@ def source_space(): "hidden " "group-data-[show=true]:flex flex-col gap-y-4 " "w-full flex-initial h-auto min-h-0 min-w-0 max-w-full lg:flex-1 " - "lg:h-full lg:w-sm lg:max-w-sm lg:shrink-0" + "lg:h-full lg:w-sm lg:max-w-sm lg:shrink-0 " + "max-lg:group-data-[show=true]:flex-1 max-lg:group-data-[show=true]:min-h-0" ), ) diff --git a/tests/test_create_page.py b/tests/test_create_page.py new file mode 100644 index 00000000..0d49ceac --- /dev/null +++ b/tests/test_create_page.py @@ -0,0 +1,179 @@ +"""Regression tests for the /create page layout. + +Guards against the mobile-unreachable bug where the entire customizer +sidebar rendered with ``hidden lg:flex`` (``display: none`` below the +Tailwind ``lg`` breakpoint, 1024px) while the *only* element that could +toggle the source-code panel (``#copy-theme-button`` flipping +``data-show`` on ``#toggle-wrapper``) lived inside that hidden aside. + +Below ``lg`` the result was a read-only card preview with every +customizer control (Style/Base/Color/Chart/Radius/Font selects, Get +Code, Shuffle, Open Preset, Reset) and the source-code panel +permanently unreachable. The fix mirrors the responsive pattern used +by the sibling ``/typeset`` page (commit e3a5428): the sidebar renders +on mobile (no ``hidden lg:flex``), selects reflow into a horizontal +scroll strip, and the toggle-reveal pair (``group-data-[show=true]`` +on ``#source-space`` plus ``max-lg:group-data-[show=true]:hidden`` on +the preview) lets mobile users switch between preview and code. + +These tests assert the class-string contract statically (no browser +required) so the regression cannot silently return. +""" + +from typing import Any + +from native.pages.create import ( + _sidebar_desktop, # pyright: ignore[reportPrivateUsage] + create_page, + preview_space, + source_space, +) + +_CLASS_DELIMS = str.maketrans({'"': " ", "(": " ", ")": " ", ",": " "}) + + +def _is_component(node: Any) -> bool: + return hasattr(node, "children") and hasattr(node, "class_name") + + +def _find_by_id(root: Any, target_id: str) -> Any: + """Depth-first search for a Reflex component whose ``id`` equals ``target_id``.""" + if _is_component(root): + cid = root.id if hasattr(root, "id") else None + if isinstance(cid, str) and cid == target_id: + return root + children = root.children if hasattr(root, "children") else None + if isinstance(children, list): + for child in children: + found = _find_by_id(child, target_id) + if found is not None: + return found + return None + + +def _class_str(component: Any) -> str: + cls = component.class_name + return cls if isinstance(cls, str) else str(cls) + + +def _tokens(cls: str) -> set[str]: + return set(cls.translate(_CLASS_DELIMS).split()) + + +def test_sidebar_is_visible_on_mobile_not_hidden_below_lg() -> None: + """The customizer aside must render on mobile; ``hidden lg:flex`` made + every sidebar control (including the source-panel toggle) unreachable + below 1024px. + """ + aside = _sidebar_desktop() + tokens = _tokens(_class_str(aside)) + + assert "hidden" not in tokens, ( + "the sidebar must not be display:none at base breakpoint; every " + "customizer control and the Get Code toggle live inside it" + ) + assert "lg:flex" not in tokens or "flex" in tokens, ( + "the aside must be visible at base breakpoint (flex), not gated " + "behind lg:flex alone" + ) + assert "flex" in tokens, "the aside must use flex at base breakpoint" + + +def test_toggle_button_is_reachable_outside_a_hidden_container() -> None: + """``#copy-theme-button`` is the only toggler of ``data-show`` on + ``#toggle-wrapper``; it must live in a sidebar that renders on mobile. + """ + page = create_page() + + toggle = _find_by_id(page, "copy-theme-button") + assert toggle is not None, "copy-theme-button toggle must exist" + + aside = _sidebar_desktop() + aside_tokens = _tokens(_class_str(aside)) + assert "hidden" not in aside_tokens, ( + "the toggle lives inside the sidebar; the sidebar must not be " + "hidden at base breakpoint or the toggle is unreachable on mobile" + ) + + wrapper = _find_by_id(page, "toggle-wrapper") + assert wrapper is not None, "toggle-wrapper must exist" + custom_attrs = getattr(wrapper, "custom_attrs", {}) or {} + assert custom_attrs.get("data-show") == "false", ( + "toggle-wrapper must start with data-show=false so the source " + "panel is hidden until toggled" + ) + + +def test_toggle_flips_data_show_on_toggle_wrapper() -> None: + """The toggle's on_click must mutate ``data-show`` on + ``#toggle-wrapper`` so the source panel can be revealed on mobile. + """ + toggle = _find_by_id(create_page(), "copy-theme-button") + assert toggle is not None + triggers = getattr(toggle, "event_triggers", {}) or {} + on_click = triggers.get("on_click") + assert on_click is not None, "toggle must have an on_click handler" + script_src = str(on_click) + assert "toggle-wrapper" in script_src, "the toggle must target #toggle-wrapper" + assert "dataset.show" in script_src, ( + "the toggle must flip data-show to reveal/hide the source panel" + ) + + +def test_source_panel_is_revealable_on_mobile() -> None: + """``#source-space`` must become visible on mobile when + ``data-show`` is flipped to true; previously the panel was + ``hidden group-data-[show=true]:flex`` but the only toggler was + hidden on mobile, so the panel could never appear. + """ + cls = source_space().class_name + assert isinstance(cls, str) + tokens = _tokens(cls) + + assert "hidden" in tokens, "base visibility must be hidden (default)" + assert "group-data-[show=true]:flex" in tokens, ( + "mobile reveal rule must trigger when #toggle-wrapper[data-show=true]" + ) + assert "max-lg:group-data-[show=true]:flex-1" in tokens, ( + "source panel must fill available height on mobile when revealed" + ) + assert "max-lg:group-data-[show=true]:min-h-0" in tokens, ( + "source panel must be allowed to shrink within flex on mobile" + ) + + +def test_preview_hides_on_mobile_when_source_shown() -> None: + """On mobile the preview must step aside when the source panel is + shown so the two don't compete for the small viewport; mirroring + ``/typeset``'s ``max-lg:group-data-[show=true]:hidden`` on the preview. + """ + page = create_page() + + wrapper = _find_by_id(page, "toggle-wrapper") + assert wrapper is not None, "toggle-wrapper must exist" + assert "group" in _tokens(_class_str(wrapper)), ( + "toggle-wrapper must carry the ``group`` class so its " + "data-show state drives group-data-[show=true] descendants" + ) + + preview_tokens = _tokens(_class_str(preview_space())) + assert "max-lg:group-data-[show=true]:hidden" in preview_tokens, ( + "preview must be hidden on mobile when #toggle-wrapper[data-show=true] " + "so it steps aside for the source panel" + ) + + +def test_create_and_typeset_share_mobile_reachability_contract() -> None: + """Quick parity check: both customizer pages must render their + sidebars on mobile (no ``hidden lg:flex``) and expose a source panel + that is revealable via a reachable toggle. + """ + from native.pages.typeset import ( + _sidebar_desktop as typeset_sidebar, # pyright: ignore[reportPrivateUsage] + ) + + create_tokens = _tokens(_class_str(_sidebar_desktop())) + typeset_tokens = _tokens(_class_str(typeset_sidebar())) + + assert "hidden" not in create_tokens, "create sidebar must render on mobile" + assert "hidden" not in typeset_tokens, "typeset sidebar must render on mobile"