← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
src/install.ts
133 lines · 4.9 KB · TypeScript/**
* Client helpers for PWA install CTA + standalone detection.
* Inspired by kody.video / court-projector; adapted for package-app fetch + SW.
*
* Client requirements (wire via Remix `InstallCta` or `installChromeHtml` + boot):
* 1. Capture `beforeinstallprompt`. Call `preventDefault()` **only** when a
* custom header CTA exists and `event.prompt` is callable, then store it.
* If we cannot show that CTA, do not preventDefault (let the native banner).
* 2. Keep a reserved header install slot; reveal the icon only when BIP fires
* (Chromium) or iOS A2HS guidance applies — visibility, not layout toggle.
* 3. On Install click, call that stored `event.prompt()` (native install UI).
* Never leave a prevented event that is never prompted.
* 4. Show Add-to-Home-Screen instructions only when UA is iOS (fixed overlay).
* 5. Hide install UI when already standalone / installed.
*
* Tip pattern: icon-only install control uses `data-tip` + `aria-label`.
* Labeled buttons (nav, Add, Check for updates) rely on their visible text.
*/
export type InstallUiOptions = {
/** App display name used in install copy. */
appName?: string
/** localStorage key for dismissing the iOS hint. */
hintDismissKey?: string
}
/**
* Detect installed / standalone display modes (iOS + Chromium).
* Use to hide install CTAs when the app is already installed.
*
* @returns Whether the app is running as an installed PWA-like display
*
* @example
* import { isStandaloneDisplay } from 'kody:@kentcdodds/package-app-kit/install'
* // browser only
* const installed = isStandaloneDisplay()
*/
export function isStandaloneDisplay(
nav: { standalone?: boolean } = typeof navigator !== 'undefined' ? (navigator as { standalone?: boolean }) : {},
win: { matchMedia?: (q: string) => { matches: boolean } } = typeof window !== 'undefined' ? window : {},
): boolean {
if (nav.standalone === true) return true
try {
if (win.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)').matches) {
return true
}
} catch {
/* ignore */
}
try {
return Boolean(win.matchMedia?.('(display-mode: standalone)').matches)
} catch {
return false
}
}
/**
* First-run Welcome splash is retired — install guidance lives on the header
* icon (and iOS overlay). Kept as a stable export that always returns `false`
* so older callers compile without painting in-flow Welcome chrome.
*
* @example
* import { shouldShowFirstRunSplash } from 'kody:@kentcdodds/package-app-kit/install'
* shouldShowFirstRunSplash({ standalone: false }) // always false
*/
export function shouldShowFirstRunSplash(_input: {
standalone: boolean
dismissed?: boolean
}): boolean {
return false
}
/**
* Detect iOS / iPadOS Safari (needs Add-to-Home-Screen instructions).
*
* @example
* import { isIosBrowser } from 'kody:@kentcdodds/package-app-kit/install'
* const ios = isIosBrowser()
*/
export function isIosBrowser(
nav: { userAgent?: string; platform?: string; maxTouchPoints?: number } = typeof navigator !== 'undefined'
? navigator
: {},
): boolean {
const ua = String(nav.userAgent || '')
if (/iphone|ipad|ipod/i.test(ua)) return true
return nav.platform === 'MacIntel' && (nav.maxTouchPoints || 0) > 1
}
/**
* HTML for a reserved header install icon slot (+ optional iOS hint overlay host).
* Slot stays in layout; the icon starts unavailable (`data-available="false"`) until
* client JS reveals it for BIP or iOS — avoids CLS when install becomes available.
*
* Place this **to the right of the site title** in the header brand row.
*
* @example
* import { installChromeHtml } from 'kody:@kentcdodds/package-app-kit/install'
* const html = installChromeHtml({ appName: 'My App' })
*/
export function installChromeHtml(options: InstallUiOptions = {}): string {
const name = options.appName || 'this app'
const tip = `Install ${name}`
return `
<span class="pak-install-slot" data-install-wrap data-available="false">
<button class="pak-btn pak-btn-icon" type="button" data-install data-tip="${escapeAttr(tip)}" aria-label="${escapeAttr(tip)}" aria-hidden="true" tabindex="-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M5 21h14"/></svg>
</button>
</span>
<div class="pak-install-hint-overlay" data-install-hint hidden>
<div class="pak-card pak-stack pak-install-hint-card">
<p class="pak-muted" data-install-hint-copy></p>
<button class="pak-btn" type="button" data-install-hint-dismiss>Dismiss</button>
</div>
</div>
`.trim()
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
function escapeAttr(value: string) {
return escapeHtml(value).replaceAll("'", ''')
}
/** Primary callable export for this subpath. */
export default installChromeHtml