Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-link-button-mobile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@frigade/react": patch
---

Fix mobile popup-blocker swallowing primary/secondary button link clicks. When a step exposes `primaryButton.uri` (or the legacy `primaryButtonUri`) and the consumer hasn't overridden the `navigate` prop, the button now renders as a native `<a href target rel>` so the browser handles navigation directly. Previously the click triggered `window.open` after an awaited `step.complete`, which iOS Safari and Chrome Android silently block as a popup because the user-gesture context was lost. Buttons without a URI, and buttons under a custom `navigate` handler, are unchanged. Visual styling is identical.
91 changes: 90 additions & 1 deletion apps/smithy/src/stories/Announcement/Announcement.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Announcement, Tour, useFlow, useFrigade } from "@frigade/react";
import {
Announcement,
FrigadeJS,
Provider,
Tour,
useFlow,
useFrigade,
} from "@frigade/react";
import { useEffect } from "react";

export default {
Expand All @@ -15,6 +22,88 @@ export const Default = {
},
};

// TEMP verification harness for the mobile popup-blocker fix. Uses __readOnly
// + __flowStateOverrides to mock an Announcement whose primary CTA opens a
// URL in a new tab. With the fix in place, the primary button renders as
// `<a href target="_blank" rel="noopener noreferrer">` so mobile browsers
// don't block the popup.
const MOCK_FLOW_ID = "flow_mock_link_button";
const linkButtonFlowOverride = {
[MOCK_FLOW_ID]: {
flowSlug: MOCK_FLOW_ID,
flowName: "Link Button Repro",
flowType: FrigadeJS.FlowType.ANNOUNCEMENT,
data: {
steps: [
{
id: "step-one",
title: "Payment links are here",
subtitle: "Now you can create an order and send your customers a link to pay through multiplate.",
primaryButton: {
title: "Learn more",
uri: "https://example.com/learn-more",
target: "_blank",
},
secondaryButton: { title: "Dismiss" },
$state: {
completed: false,
started: false,
visible: true,
blocked: false,
skipped: false,
},
},
],
},
$state: {
currentStepId: "step-one",
visible: true,
started: false,
completed: false,
skipped: false,
currentStepIndex: 0,
},
},
};

export const PrimaryButtonAsLink = {
args: { flowId: MOCK_FLOW_ID, modal: true, dismissible: true },
decorators: [
(Story, { args }) => (
<Provider
apiKey="api_storybook_mock_link_button"
userId="storybook_mock_user"
__readOnly={true}
__flowStateOverrides={linkButtonFlowOverride}
>
<Story {...args} />
</Provider>
),
],
};

// Same mock flow, but with a custom navigate handler — should fall back to
// rendering as <button> so the consumer's navigate gets called as before.
export const PrimaryButtonLinkWithCustomNavigate = {
args: { flowId: MOCK_FLOW_ID, modal: true, dismissible: true },
decorators: [
(Story, { args }) => (
<Provider
apiKey="api_storybook_mock_link_button_custom_nav"
userId="storybook_mock_user_2"
__readOnly={true}
__flowStateOverrides={linkButtonFlowOverride}
navigate={(url, target) => {
console.log("custom navigate:", url, target);
window.open(url, target);
}}
>
<Story {...args} />
</Provider>
),
],
};

export const TestReset = {
args: {
dismissible: true,
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/components/Announcement/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export function Announcement({ flowId, ...props }: AnnouncementProps) {
{({
flow,
handleDismiss,
handlePrimary,
handleSecondary,
primaryButtonProps,
secondaryButtonProps,
parentProps: { containerProps, dismissible },
step,
}) => {
Expand Down Expand Up @@ -71,7 +71,7 @@ export function Announcement({ flowId, ...props }: AnnouncementProps) {

<Flex.Row
css={{
'& > button': {
'& > button, & > a': {
flexBasis: '50%',
flexGrow: 1,
},
Expand All @@ -82,15 +82,15 @@ export function Announcement({ flowId, ...props }: AnnouncementProps) {
{secondaryButtonTitle && (
<Dialog.Secondary
disabled={disabled}
onClick={handleSecondary}
title={secondaryButtonTitle}
{...secondaryButtonProps}
/>
)}
{primaryButtonTitle && (
<Dialog.Primary
disabled={disabled}
onClick={handlePrimary}
title={primaryButtonTitle}
{...primaryButtonProps}
/>
)}
</Flex.Row>
Expand Down
12 changes: 8 additions & 4 deletions packages/react/src/components/Banner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export function Banner({ flowId, ...props }: BannerProps) {
<Flow as={null} flowId={flowId} {...props}>
{({
handleDismiss,
handlePrimary,
handleSecondary,
primaryButtonProps,
secondaryButtonProps,
parentProps: { containerProps, dismissible },
step,
}) => {
Expand Down Expand Up @@ -51,9 +51,13 @@ export function Banner({ flowId, ...props }: BannerProps) {
<Card.Secondary
disabled={disabled}
title={secondaryButtonTitle}
onClick={handleSecondary}
{...secondaryButtonProps}
/>
<Card.Primary
disabled={disabled}
title={primaryButtonTitle}
{...primaryButtonProps}
/>
<Card.Primary disabled={disabled} title={primaryButtonTitle} onClick={handlePrimary} />
{dismissible && <Card.Dismiss onClick={handleDismiss} />}
</Card>
)
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/components/Button/Button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ const base = {
borderWidth: 'md',
borderRadius: 'md',
borderStyle: 'solid',
cursor: 'pointer',
'cursor:disabled': 'not-allowed',
display: 'flex',
gap: '2',
padding: '2 4',
fontFamily: 'inherit',
textDecoration: 'none',

'opacity:disabled': '0.6',
'pointerEvents:disabled': 'none',
Expand Down
12 changes: 9 additions & 3 deletions packages/react/src/components/Card/FlowCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ export function FlowCard({ part, ...props }: FlowProps) {
part={['card', part]}
{...props}
>
{({ handleDismiss, handlePrimary, handleSecondary, parentProps: { dismissible }, step }) => {
{({
handleDismiss,
primaryButtonProps,
secondaryButtonProps,
parentProps: { dismissible },
step,
}) => {
const primaryButtonTitle = step.primaryButton?.title ?? step.primaryButtonTitle
const secondaryButtonTitle = step.secondaryButton?.title ?? step.secondaryButtonTitle

Expand All @@ -38,8 +44,8 @@ export function FlowCard({ part, ...props }: FlowProps) {
/>

<Flex.Row gap={3} justifyContent="flex-end" part="card-footer">
<Card.Secondary title={secondaryButtonTitle} onClick={handleSecondary} />
<Card.Primary title={primaryButtonTitle} onClick={handlePrimary} />
<Card.Secondary title={secondaryButtonTitle} {...secondaryButtonProps} />
<Card.Primary title={primaryButtonTitle} {...primaryButtonProps} />
</Flex.Row>
</>
)
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/components/Checklist/CarouselStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ interface CarouselStepProps {
}

export function CarouselStep({ onPrimary, onSecondary, step }: CarouselStepProps) {
const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary })
const { primaryButtonProps, secondaryButtonProps } = useStepHandlers(step, {
onPrimary,
onSecondary,
})

const { blocked, completed, skipped } = step.$state

Expand Down Expand Up @@ -63,7 +66,7 @@ export function CarouselStep({ onPrimary, onSecondary, step }: CarouselStepProps
<Flex.Row
css={{
'@container (max-width: 200px)': {
'& > button': {
'& > button, & > a': {
flexBasis: '50%',
flexGrow: 1,
},
Expand All @@ -78,13 +81,13 @@ export function CarouselStep({ onPrimary, onSecondary, step }: CarouselStepProps
>
<Card.Secondary
disabled={blocked}
onClick={handleSecondary}
title={step.secondaryButton?.title}
{...secondaryButtonProps}
/>
<Card.Primary
disabled={blocked}
onClick={handlePrimary}
title={step.primaryButton?.title}
{...primaryButtonProps}
/>
</Flex.Row>
</Card>
Expand Down
17 changes: 12 additions & 5 deletions packages/react/src/components/Checklist/Collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export interface CollapsibleProps extends FlowPropsWithoutChildren {
}

function DefaultCollapsibleStep({
handlePrimary,
handleSecondary,
primaryButtonProps,
secondaryButtonProps,
open,
onOpenChange,
step,
Expand Down Expand Up @@ -100,8 +100,12 @@ function DefaultCollapsibleStep({
/>
<Card.Subtitle color="neutral.400">{subtitle}</Card.Subtitle>
<Flex.Row gap={3} part="collapsible-footer">
<Card.Secondary title={secondaryButtonTitle} onClick={handleSecondary} />
<Card.Primary disabled={disabled} title={primaryButtonTitle} onClick={handlePrimary} />
<Card.Secondary title={secondaryButtonTitle} {...secondaryButtonProps} />
<Card.Primary
disabled={disabled}
title={primaryButtonTitle}
{...primaryButtonProps}
/>
</Flex.Row>
</CollapsibleStep.Content>
</CollapsibleStep.Root>
Expand All @@ -115,7 +119,8 @@ const defaultStepTypes = {
function StepWrapper({ flow, step, ...props }: FlowChildrenProps) {
const { onPrimary, onSecondary, openStepId, setOpenStepId, stepTypes } =
useContext(CollapsibleContext)
const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary })
const { handlePrimary, handleSecondary, primaryButtonProps, secondaryButtonProps } =
useStepHandlers(step, { onPrimary, onSecondary })

const open = (openStepId ?? flow.getCurrentStep().id) === step.id

Expand All @@ -141,6 +146,8 @@ function StepWrapper({ flow, step, ...props }: FlowChildrenProps) {
{...props}
handlePrimary={handlePrimary}
handleSecondary={handleSecondary}
primaryButtonProps={primaryButtonProps}
secondaryButtonProps={secondaryButtonProps}
/>
)
}
Expand Down
9 changes: 6 additions & 3 deletions packages/react/src/components/Checklist/FloatingStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { floatingTransitionCSS } from '@/components/Checklist/Floating.styles'

export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId, step }) {
const anchorPointerEnterTimeout = useRef<ReturnType<typeof setTimeout>>()
const { handlePrimary, handleSecondary } = useStepHandlers(step, { onPrimary, onSecondary })
const { handlePrimary, handleSecondary, primaryButtonProps, secondaryButtonProps } =
useStepHandlers(step, { onPrimary, onSecondary })

const isStepOpen = openStepId === step.id

Expand Down Expand Up @@ -90,15 +91,17 @@ export function FloatingStep({ onPrimary, onSecondary, openStepId, setOpenStepId
<Flex.Row gap={3} justifyContent="flex-end" part="card-footer">
<Card.Secondary
disabled={step.$state.blocked}
onClick={wrappedHandleSecondary}
padding="1 2"
title={secondaryButtonTitle}
{...secondaryButtonProps}
onClick={wrappedHandleSecondary}
/>
<Card.Primary
disabled={step.$state.blocked}
onClick={wrappedHandlePrimary}
padding="1 2"
title={primaryButtonTitle}
{...primaryButtonProps}
onClick={wrappedHandlePrimary}
/>
</Flex.Row>
</Card>
Expand Down
17 changes: 16 additions & 1 deletion packages/react/src/components/Flow/FlowProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type { Flow as FlowType, FlowStep } from '@frigade/js'
import type { BoxProps } from '@/components/Box'

import type { DismissHandler, FlowHandlerProp } from '@/hooks/useFlowHandlers'
import type { StepHandler, StepHandlerProp } from '@/hooks/useStepHandlers'
import type {
ButtonLinkProps,
StepHandler,
StepHandlerProp,
} from '@/hooks/useStepHandlers'

export interface BoxPropsWithoutChildren extends Omit<BoxProps, 'children'> {}

Expand Down Expand Up @@ -93,6 +97,17 @@ export interface FlowChildrenProps {
handleDismiss: DismissHandler
handlePrimary: StepHandler
handleSecondary: StepHandler
/**
* Props to spread on the primary button component. When the step exposes a
* URI and the consumer hasn't overridden `navigate`, these include
* `as="a" href target rel` so the rendered element is a native anchor —
* native anchor clicks are not subject to mobile popup blocking.
*/
primaryButtonProps: { onClick: StepHandler } & ButtonLinkProps
/**
* Props to spread on the secondary button component. See `primaryButtonProps`.
*/
secondaryButtonProps: { onClick: StepHandler } & ButtonLinkProps
parentProps: ParentProps
step: FlowStep
}
11 changes: 7 additions & 4 deletions packages/react/src/components/Flow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ export function Flow({
onDismiss,
})

const { handlePrimary, handleSecondary } = useStepHandlers(step, {
onPrimary,
onSecondary,
})
const { handlePrimary, handleSecondary, primaryButtonProps, secondaryButtonProps } =
useStepHandlers(step, {
onPrimary,
onSecondary,
})

const isModal =
mergedProps?.modal ||
Expand Down Expand Up @@ -153,6 +154,8 @@ export function Flow({
handleDismiss,
handlePrimary,
handleSecondary,
primaryButtonProps,
secondaryButtonProps,
parentProps: {
as,
dismissible,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/components/Provider/FrigadeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ProviderContext extends Omit<ProviderProps, 'children' | 'theme
currentModal: string | null
setCurrentModal: Dispatch<SetStateAction<string | null>>
frigade?: Frigade
hasCustomNavigate: boolean
hasInitialized: boolean
registerComponent: (flowId: string, callback?: CollectionsRegistryCallback) => void
unregisterComponent: (flowId: string) => void
Expand All @@ -18,6 +19,7 @@ export const FrigadeContext = createContext<ProviderContext>({
currentModal: null,
setCurrentModal: () => {},
navigate: () => {},
hasCustomNavigate: false,
hasInitialized: false,
registerComponent: () => {},
unregisterComponent: () => {},
Expand Down
Loading
Loading