Skip to content

fix: render URI buttons as anchors to fix mobile popup blocker#489

Merged
christianmat merged 2 commits into
mainfrom
fix/link-button-mobile-popup-blocker
May 22, 2026
Merged

fix: render URI buttons as anchors to fix mobile popup blocker#489
christianmat merged 2 commits into
mainfrom
fix/link-button-mobile-popup-blocker

Conversation

@christianmat
Copy link
Copy Markdown
Contributor

Summary

  • Fixes a customer-reported bug where the "Learn more" primary button in an Announcement opens a new tab on desktop but silently does nothing on mobile (Slack thread w/ Sarah Yang).
  • Root cause: useStepHandlers.handlePrimary calls navigate(uri, target) (default window.open) after await step.complete(...). iOS Safari and Chrome Android only honor window.open(_, "_blank") while the user gesture is active — any await breaks the gesture chain and the new tab is silently blocked. Desktop browsers are lax about this, which is why it works there.
  • Fix: when a step exposes a URI and the consumer hasn't overridden navigate, render the button as <a href target rel="noopener noreferrer"> instead of <button>. Native anchor clicks are never popup-blocked. onClick still fires step.complete for analytics/state. Buttons with no URI and buttons under a custom navigate prop are unchanged.

What changes

  • useStepHandlers returns new primaryButtonProps / secondaryButtonProps objects that include { as: 'a', href, target, rel } when appropriate. Call sites (Announcement, Banner, Card.FlowCard, Tour.TourStep, Checklist.Collapsible, Checklist.Carousel, Checklist.Floating) spread these in place of onClick={handlePrimary}.
  • FrigadeContext gains hasCustomNavigate: boolean so the hook can detect when a custom navigate was passed and fall back to button rendering.
  • Button.styles.ts adds cursor: pointer and text-decoration: none to the base styles so an <a> matches a <button> in every browser. Computed font/size/weight/line-height are already inherited from the inner Text.Body2 so were already identical.
  • Form's submit button is unchanged — its primary action is submission, not navigation.

Test plan

  • tsc --noEmit clean
  • Two new Storybook stories under Components / Announcement: PrimaryButtonAsLink (renders <a> with default navigate) and PrimaryButtonLinkWithCustomNavigate (renders <button> when consumer overrides navigate). Mock the flow via __readOnly + __flowStateOverrides.
  • Headless Playwright verification confirms:
    • URI + default navigate → <a href="..." target="_blank" rel="noopener noreferrer">
    • No URI → <button>
    • URI + custom navigate → <button>
  • Visual screenshots of the URI and custom-navigate variants are pixel-identical except for the underlying tag name.
  • Manual mobile verification (open the deployed announcement flow on a real iOS / Android device after release).

🤖 Generated with Claude Code

christianmat and others added 2 commits May 21, 2026 16:17
Primary/secondary buttons whose step exposes a URI used to navigate via
window.open() after awaiting step.complete. iOS Safari and Chrome Android
only honor window.open(_, '_blank') when it runs synchronously inside the
user gesture — any await breaks that gesture chain and the new tab is
silently dropped, so users see the flow complete but no tab opens.

The buttons now render as <a href target rel="noopener noreferrer"> so
the browser handles navigation natively, which is never popup-blocked.
The button's onClick still fires step.complete for analytics/state.

When the consumer overrides the navigate prop (e.g. next/router.push),
the button keeps rendering as <button> so the custom handler still runs
exactly as before. Visual styling is unchanged — computed text styles
(font, size, weight, line height) are pixel-identical and the base
button styles now explicitly set cursor:pointer and text-decoration:none
so the anchor matches the button in every browser.

Storybook stories added under Components/Announcement:
PrimaryButtonAsLink (renders as <a>) and
PrimaryButtonLinkWithCustomNavigate (renders as <button>).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ousel

The footer CSS used `& > button { flex-basis: 50%; flex-grow: 1 }` to make
primary and secondary share the row evenly. When the primary renders as
an anchor (the link-button path) the selector misses it and only the
button-shaped secondary gets the 50/50 rule, so the anchor shrinks to its
content width and the button stretches to fill the rest.

Match `& > button, & > a` so both element types size identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@christianmat christianmat merged commit aea7dde into main May 22, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant