← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
starter-remix/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_ID__-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>
)
}
},
)