Просмотр исходного кода

Let the installed app fill the screen

An installed web app cannot ask to launch fullscreen on a desktop. Chrome
and Edge honour the manifest's fullscreen display mode on Android and
ChromeOS only, and fall back to a plain window on every desktop, so the
manifest alone cannot answer this. The Fullscreen API can, but it needs a
user gesture and so cannot fire on load.

So the manifest asks for fullscreen where asking works, and the app grows
a toggle for where it does not. The toggle remembers the answer and spends
it on the first click or keypress after launch: open the app, click
anything, it fills the screen.

Only ever automatic for the installed app. Taking over the first click of
a browser tab somebody opened alongside others is a hijack, not a feature.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
7cf65814d8

+ 3 - 1
messages/de/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barcode eingeben…",
     "scanHint": "Richten Sie die Kamera auf einen Barcode oder geben Sie ihn manuell ein",
     "iosInstallHint": "Auf Teilen > Zum Home-Bildschirm tippen",
-    "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff"
+    "installDescription": "Zum Home-Bildschirm hinzufügen für schnellen Zugriff",
+    "enterFullscreen": "Vollbild",
+    "exitFullscreen": "Vollbild beenden"
   }
 }

+ 3 - 1
messages/en/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Enter barcode...",
     "scanHint": "Point your camera at a barcode or enter it manually",
     "iosInstallHint": "Tap Share > Add to Home Screen",
-    "installDescription": "Add to your home screen for quick access"
+    "installDescription": "Add to your home screen for quick access",
+    "enterFullscreen": "Fullscreen",
+    "exitFullscreen": "Exit fullscreen"
   }
 }

+ 3 - 1
messages/es/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Introducir código de barras…",
     "scanHint": "Apunte la cámara a un código de barras o introdúzcalo manualmente",
     "iosInstallHint": "Toque Compartir > Añadir a inicio",
-    "installDescription": "Añádela a tu pantalla de inicio para acceder rápido"
+    "installDescription": "Añádela a tu pantalla de inicio para acceder rápido",
+    "enterFullscreen": "Pantalla completa",
+    "exitFullscreen": "Salir de pantalla completa"
   }
 }

+ 3 - 1
messages/fr/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Saisir un code-barres…",
     "scanHint": "Pointez la caméra vers un code-barres ou saisissez-le manuellement",
     "iosInstallHint": "Touchez Partager > Sur l'écran d'accueil",
-    "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide"
+    "installDescription": "Ajoutez-la à votre écran d'accueil pour un accès rapide",
+    "enterFullscreen": "Plein écran",
+    "exitFullscreen": "Quitter le plein écran"
   }
 }

+ 3 - 1
messages/it/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Inserisci codice a barre…",
     "scanHint": "Punta la fotocamera su un codice a barre oppure inseriscilo manualmente",
     "iosInstallHint": "Tocca Condividi > Aggiungi a Home",
-    "installDescription": "Aggiungila alla schermata Home per un accesso rapido"
+    "installDescription": "Aggiungila alla schermata Home per un accesso rapido",
+    "enterFullscreen": "Schermo intero",
+    "exitFullscreen": "Esci da schermo intero"
   }
 }

+ 3 - 1
messages/lt/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Įveskite brūkšninį kodą…",
     "scanHint": "Nukreipkite kamerą į brūkšninį kodą arba įveskite jį ranka",
     "iosInstallHint": "Palieskite Bendrinti > Įtraukti į pradžios ekraną",
-    "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau"
+    "installDescription": "Įtraukite į pradžios ekraną, kad pasiektumėte greičiau",
+    "enterFullscreen": "Visas ekranas",
+    "exitFullscreen": "Išjungti visą ekraną"
   }
 }

+ 3 - 1
messages/nb/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Skriv inn strekkode…",
     "scanHint": "Rett kameraet mot en strekkode, eller skriv den inn manuelt",
     "iosInstallHint": "Trykk Del > Legg til på Hjem-skjerm",
-    "installDescription": "Legg den til på hjem-skjermen for rask tilgang"
+    "installDescription": "Legg den til på hjem-skjermen for rask tilgang",
+    "enterFullscreen": "Fullskjerm",
+    "exitFullscreen": "Avslutt fullskjerm"
   }
 }

+ 3 - 1
messages/nl/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barcode invoeren…",
     "scanHint": "Richt uw camera op een barcode of voer hem handmatig in",
     "iosInstallHint": "Tik op Deel > Zet op beginscherm",
-    "installDescription": "Zet de app op je beginscherm voor snelle toegang"
+    "installDescription": "Zet de app op je beginscherm voor snelle toegang",
+    "enterFullscreen": "Volledig scherm",
+    "exitFullscreen": "Volledig scherm afsluiten"
   }
 }

+ 3 - 1
messages/pl/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Wprowadź kod kreskowy…",
     "scanHint": "Skieruj aparat na kod kreskowy albo wprowadź go ręcznie",
     "iosInstallHint": "Dotknij Udostępnij > Do ekranu początkowego",
-    "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp"
+    "installDescription": "Dodaj do ekranu głównego, aby mieć szybki dostęp",
+    "enterFullscreen": "Pełny ekran",
+    "exitFullscreen": "Zamknij pełny ekran"
   }
 }

+ 3 - 1
messages/pt-BR/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Digitar código de barras…",
     "scanHint": "Aponte a câmera para um código de barras ou digite-o manualmente",
     "iosInstallHint": "Toque em Compartilhar > Adicionar à Tela de Início",
-    "installDescription": "Adicione à tela de início para acesso rápido"
+    "installDescription": "Adicione à tela de início para acesso rápido",
+    "enterFullscreen": "Tela cheia",
+    "exitFullscreen": "Sair da tela cheia"
   }
 }

+ 3 - 1
messages/ru/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Введите штрихкод…",
     "scanHint": "Наведите камеру на штрихкод или введите его вручную",
     "iosInstallHint": "Нажмите «Поделиться» > «На экран «Домой»»",
-    "installDescription": "Добавьте на главный экран для быстрого доступа"
+    "installDescription": "Добавьте на главный экран для быстрого доступа",
+    "enterFullscreen": "Полный экран",
+    "exitFullscreen": "Выйти из полного экрана"
   }
 }

+ 3 - 1
messages/tr/common.json

@@ -77,6 +77,8 @@
     "enterBarcode": "Barkod girin…",
     "scanHint": "Kamerayı bir barkoda doğrultun ya da elle girin",
     "iosInstallHint": "Paylaş > Ana Ekrana Ekle'ye dokunun",
-    "installDescription": "Hızlı erişim için ana ekranınıza ekleyin"
+    "installDescription": "Hızlı erişim için ana ekranınıza ekleyin",
+    "enterFullscreen": "Tam ekran",
+    "exitFullscreen": "Tam ekrandan çık"
   }
 }

+ 145 - 0
src/__tests__/components/fullscreen-toggle.test.tsx

@@ -0,0 +1,145 @@
+/**
+ * The fullscreen control for the installed app.
+ *
+ * Two things here are easy to get wrong and invisible when they are. The
+ * remembered preference must only ever be spent inside the installed app,
+ * because hijacking the first click on a browser tab is hostile. And the
+ * preference has to be written on the way out as well as the way in, or
+ * turning fullscreen off is undone by the next launch.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+
+let installed = false
+vi.mock('@/components/pwa-install-prompt', () => ({
+  useInstallPrompt: () => ({ installed }),
+  SidebarInstallButton: () => null,
+}))
+
+vi.mock('next-intl', () => ({
+  useTranslations: () => (key: string) => key,
+}))
+
+vi.mock('@/components/ui/sidebar', () => ({
+  SidebarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+  SidebarMenuButton: ({
+    children,
+    onClick,
+  }: {
+    children: React.ReactNode
+    onClick?: () => void
+  }) => (
+    <button type="button" data-testid="toggle" onClick={onClick}>
+      {children}
+    </button>
+  ),
+}))
+
+const { FullscreenToggle } = await import('@/components/fullscreen-toggle')
+
+const requestFullscreen = vi.fn().mockResolvedValue(undefined)
+const exitFullscreen = vi.fn().mockResolvedValue(undefined)
+
+function setFullscreen(on: boolean) {
+  Object.defineProperty(document, 'fullscreenElement', {
+    value: on ? document.documentElement : null,
+    configurable: true,
+  })
+}
+
+beforeEach(() => {
+  installed = false
+  localStorage.clear()
+  requestFullscreen.mockClear()
+  exitFullscreen.mockClear()
+  Object.defineProperty(document, 'fullscreenEnabled', { value: true, configurable: true })
+  document.documentElement.requestFullscreen = requestFullscreen
+  document.exitFullscreen = exitFullscreen
+  setFullscreen(false)
+})
+
+afterEach(() => {
+  localStorage.clear()
+})
+
+describe('the remembered preference', () => {
+  it('is spent on the first gesture inside the installed app', () => {
+    installed = true
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    expect(requestFullscreen).not.toHaveBeenCalled()
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).toHaveBeenCalled()
+  })
+
+  it('is ignored in a browser tab', () => {
+    // Taking over the first click of a page somebody opened alongside others
+    // is the difference between a feature and a hijack.
+    installed = false
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).not.toHaveBeenCalled()
+  })
+
+  it('is not spent when nobody asked for it', () => {
+    installed = true
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).not.toHaveBeenCalled()
+  })
+
+  it('is spent once, not on every click', () => {
+    installed = true
+    localStorage.setItem('app-fullscreen', 'true')
+    render(<FullscreenToggle />)
+
+    act(() => {
+      document.dispatchEvent(new Event('pointerdown'))
+      document.dispatchEvent(new Event('pointerdown'))
+    })
+    expect(requestFullscreen).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('the button', () => {
+  it('records the choice when turning fullscreen on', () => {
+    render(<FullscreenToggle />)
+    act(() => {
+      screen.getByTestId('toggle').click()
+    })
+    expect(requestFullscreen).toHaveBeenCalled()
+    expect(localStorage.getItem('app-fullscreen')).toBe('true')
+  })
+
+  it('records the choice when turning it off, so the next launch respects it', () => {
+    localStorage.setItem('app-fullscreen', 'true')
+    setFullscreen(true)
+    render(<FullscreenToggle />)
+
+    act(() => {
+      screen.getByTestId('toggle').click()
+    })
+    expect(exitFullscreen).toHaveBeenCalled()
+    expect(localStorage.getItem('app-fullscreen')).toBe('false')
+  })
+
+  it('is absent where the browser has no fullscreen to give', () => {
+    // iOS Safari on the phone, where requesting it throws rather than refuses.
+    Object.defineProperty(document, 'fullscreenEnabled', { value: false, configurable: true })
+    render(<FullscreenToggle />)
+    expect(screen.queryByTestId('toggle')).toBeNull()
+  })
+})

+ 5 - 0
src/app/manifest.ts

@@ -9,6 +9,11 @@ export default function manifest(): MetadataRoute.Manifest {
       'Workshop management platform for automotive service businesses. Manage work orders, invoices, customers, inventory, and vehicle service history.',
     start_url: '/',
     scope: '/',
+    // Chrome and Edge honour 'fullscreen' on Android and ChromeOS, which is
+    // what a tablet in the bay wants. Every desktop falls back down the chain
+    // to 'standalone', because no desktop browser launches a web app
+    // fullscreen from the manifest. The in-app toggle covers that case.
+    display_override: ['fullscreen', 'standalone'],
     display: 'standalone',
     orientation: 'any',
     background_color: '#09090b',

+ 2 - 0
src/components/app-sidebar.tsx

@@ -81,6 +81,7 @@ import {
   NotificationPanel,
 } from '@/features/notifications/Components/NotificationPanel'
 import { SidebarInstallButton } from '@/components/pwa-install-prompt'
+import { FullscreenToggle } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
 import { cn } from '@/lib/utils'
 
@@ -453,6 +454,7 @@ export function AppSidebar({
           </SidebarGroup>
         )}
         <SidebarInstallButton />
+        <FullscreenToggle />
       </SidebarContent>
       <SidebarFooter>
         <SidebarMenu>

+ 131 - 0
src/components/fullscreen-toggle.tsx

@@ -0,0 +1,131 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Maximize, Minimize } from 'lucide-react'
+import {
+  SidebarGroup,
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+} from '@/components/ui/sidebar'
+import { useInstallPrompt } from '@/components/pwa-install-prompt'
+
+/**
+ * Kept in localStorage, not in workshop settings, unlike the feature hints.
+ *
+ * This one is genuinely per-device. The desk machine has a keyboard and other
+ * windows to switch between; the tablet in the bay is held in one hand and
+ * wants every pixel. The same person wants opposite answers on each, so a
+ * workshop-wide setting would be wrong on one of them.
+ */
+const PREF_KEY = 'app-fullscreen'
+
+/**
+ * A refused fullscreen request is not worth reporting.
+ *
+ * The browser refuses for reasons the person cannot act on, such as a
+ * permissions policy on an embedded install, and the button not visibly doing
+ * anything says everything a toast would. The button's own state resyncs from
+ * the fullscreenchange event either way.
+ */
+function ignoreRefusal() {
+  return undefined
+}
+
+function prefersFullscreen(): boolean {
+  try {
+    return localStorage.getItem(PREF_KEY) === 'true'
+  } catch {
+    return false
+  }
+}
+
+/**
+ * Fullscreen for the installed app.
+ *
+ * An installed web app cannot ask to launch fullscreen on a desktop: Chrome
+ * and Edge honour the manifest's fullscreen display mode on Android and
+ * ChromeOS, and fall back to a plain window everywhere else. The only route on
+ * a desktop is the Fullscreen API, which needs a user gesture and so cannot
+ * fire on load.
+ *
+ * So the preference is remembered and spent on the first gesture after launch:
+ * open the app, click anything, and it fills the screen. That is as close to
+ * "launches fullscreen" as the platform allows, and it stays honest about
+ * needing the click rather than failing silently on load.
+ *
+ * Only ever automatic for the installed app. Doing this to a browser tab would
+ * hijack the first click on a page somebody opened alongside others.
+ */
+export function FullscreenToggle() {
+  const t = useTranslations('common.shared')
+  const { installed } = useInstallPrompt()
+  const [supported, setSupported] = useState(false)
+  const [active, setActive] = useState(false)
+
+  useEffect(() => {
+    setSupported(typeof document !== 'undefined' && document.fullscreenEnabled)
+    const sync = () => setActive(document.fullscreenElement !== null)
+    sync()
+    document.addEventListener('fullscreenchange', sync)
+    return () => document.removeEventListener('fullscreenchange', sync)
+  }, [])
+
+  // Spend the remembered preference on the first click or keypress. Listening
+  // once, in capture, so the gesture still reaches whatever was clicked.
+  useEffect(() => {
+    if (!installed || !supported) return
+    if (!prefersFullscreen()) return
+    if (document.fullscreenElement) return
+
+    const enter = () => {
+      void document.documentElement.requestFullscreen().catch(ignoreRefusal)
+    }
+    document.addEventListener('pointerdown', enter, { once: true, capture: true })
+    document.addEventListener('keydown', enter, { once: true, capture: true })
+    return () => {
+      document.removeEventListener('pointerdown', enter, { capture: true })
+      document.removeEventListener('keydown', enter, { capture: true })
+    }
+  }, [installed, supported])
+
+  const toggle = useCallback(() => {
+    if (document.fullscreenElement) {
+      // Turning it off is also a decision: the next launch should respect it.
+      try {
+        localStorage.setItem(PREF_KEY, 'false')
+      } catch {
+        // Private mode, or storage disabled. The toggle still works for now.
+      }
+      void document.exitFullscreen().catch(ignoreRefusal)
+      return
+    }
+    try {
+      localStorage.setItem(PREF_KEY, 'true')
+    } catch {
+      // As above.
+    }
+    void document.documentElement.requestFullscreen().catch(ignoreRefusal)
+  }, [])
+
+  if (!supported) return null
+
+  return (
+    <SidebarGroup>
+      <SidebarMenu>
+        <SidebarMenuItem>
+          <SidebarMenuButton
+            onClick={toggle}
+            tooltip={active ? t('exitFullscreen') : t('enterFullscreen')}
+          >
+            {active ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
+            <span className="font-medium">
+              {active ? t('exitFullscreen') : t('enterFullscreen')}
+            </span>
+          </SidebarMenuButton>
+        </SidebarMenuItem>
+      </SidebarMenu>
+    </SidebarGroup>
+  )
+}