Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

app/ui/install-cta.tsx

175 lines · 5.1 KB · TypeScript
/** @jsxRuntime automatic */
/** @jsxImportSource remix/ui */
import { clientEntry, on, type Handle } from 'remix/ui'
import { IconDownload } from '../icons/download.tsx'

type DeferredPrompt = {
	prompt: () => Promise<void>
	userChoice?: Promise<unknown>
}

/**
 * Module-level capture so island remounts cannot drop a preventDefault'd BIP
 * event (Chrome warns if preventDefault runs and prompt() never does).
 */
let capturedPrompt: DeferredPrompt | null = null
let bipListenerBound = false
let onCapturedChange: (() => void) | null = null

function bindBeforeInstallPrompt() {
	if (typeof window === 'undefined' || bipListenerBound) return
	bipListenerBound = true
	window.addEventListener('beforeinstallprompt', (event) => {
		const promptFn = (event as unknown as DeferredPrompt).prompt
		// Only suppress the native banner when a header Install CTA can call prompt().
		if (typeof promptFn !== 'function' || !document.querySelector('[data-install]')) {
			return
		}
		event.preventDefault()
		capturedPrompt = event as unknown as DeferredPrompt
		onCapturedChange?.()
	})
	window.addEventListener('appinstalled', () => {
		capturedPrompt = null
		onCapturedChange?.()
	})
}

async function promptCapturedInstall() {
	const event = capturedPrompt
	if (typeof event?.prompt !== 'function') return false
	capturedPrompt = null
	try {
		// Must run in the user-gesture turn — do not re-render before prompt().
		await event.prompt()
		if (event.userChoice) await event.userChoice
		return true
	} catch {
		return false
	}
}

/**
 * Header install control (reserved slot) + optional iOS A2HS overlay.
 *
 * Install is an icon-only button in a reserved header slot (no CLS when BIP
 * arrives). Tip is appropriate here — the control has no visible text label.
 *
 * iOS Add-to-Home-Screen copy opens as a fixed overlay from the header icon.
 * There is no Welcome / first-run splash in the document flow.
 */
export const InstallCta = clientEntry(
	'kody:app#InstallCta',
	function InstallCta(handle: Handle<{ appName: string }>) {
		let standalone = false
		let ios = false
		let hintDismissed = false
		let iosHintOpen = false
		const hintKey = 'package-app-kit-demo-install-hint-dismissed'

		function detect() {
			try {
				standalone =
					(navigator as { standalone?: boolean }).standalone === true ||
					window.matchMedia('(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)')
						.matches
			} catch {
				standalone = false
			}
			ios =
				/iphone|ipad|ipod/i.test(navigator.userAgent) ||
				(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1)
			try {
				hintDismissed = localStorage.getItem(hintKey) !== null
			} catch {
				/* ignore */
			}
		}

		if (typeof window !== 'undefined') {
			detect()
			bindBeforeInstallPrompt()
			onCapturedChange = () => {
				detect()
				handle.update()
			}
		}

		return () => {
			// SSR keeps the reserved slot; reveal the control only in the browser.
			const clientReady = typeof window !== 'undefined'
			if (clientReady) detect()
			const canNative = Boolean(capturedPrompt?.prompt)
			const showIos = clientReady && !standalone && ios && !hintDismissed && !canNative
			const installAvailable = canNative || showIos
			const tip = `Install ${handle.props.appName}`

			return (
				<span class="pak-install-root">
					{/*
					  Reserved same-size slot to the right of the title.
					  When BIP / iOS install guidance is unavailable, keep the slot
					  but hide the control (visibility) so appearing later is CLS-free.
					*/}
					<div
						class="pak-install-slot"
						data-install-wrap
						data-available={installAvailable ? 'true' : 'false'}
					>
						<button
							class="pak-btn pak-btn-icon"
							type="button"
							data-install
							data-tip={tip}
							aria-label={tip}
							aria-hidden={installAvailable ? undefined : 'true'}
							tabindex={installAvailable ? undefined : -1}
							mix={on('click', async () => {
								if (capturedPrompt?.prompt) {
									await promptCapturedInstall()
									detect()
									handle.update()
									return
								}
								if (showIos) {
									iosHintOpen = !iosHintOpen
									handle.update()
								}
							})}
						>
							<IconDownload size={20} />
						</button>
					</div>

					{showIos && iosHintOpen ? (
						<div class="pak-install-hint-overlay" data-install-hint role="dialog" aria-label="Add to Home Screen">
							<div class="pak-card pak-stack pak-install-hint-card">
								<p class="pak-muted">
									Add <strong>{handle.props.appName}</strong> to your Home Screen: tap{' '}
									<strong>Share</strong>, then <strong>Add to Home Screen</strong>.
								</p>
								<button
									class="pak-btn"
									type="button"
									data-install-hint-dismiss
									mix={on('click', () => {
										try {
											localStorage.setItem(hintKey, String(Date.now()))
										} catch {
											/* ignore */
										}
										hintDismissed = true
										iosHintOpen = false
										handle.update()
									})}
								>
									Dismiss
								</button>
							</div>
						</div>
					) : null}
				</span>
			)
		}
	},
)