Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #151.
This commit is contained in:
@@ -312,6 +312,13 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.keepOnHint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The A / A / A control: an outlined pill, the size chosen in grape, each
|
||||
A drawn at the size it stands for. */
|
||||
.readingHeader .textSize {
|
||||
|
||||
@@ -326,7 +326,8 @@ const textSizes: SegmentedOption<TextSize>[] = [
|
||||
// is planned for, its title, Keep screen on and the text size.
|
||||
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
|
||||
const keepOnId = useId()
|
||||
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
|
||||
const keepOnHintId = useId()
|
||||
const keepScreenOn = useKeepScreenOn()
|
||||
const { failure } = textSize
|
||||
return (
|
||||
<>
|
||||
@@ -338,7 +339,13 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
|
||||
</div>
|
||||
<div className={styles.keepOn}>
|
||||
<span id={keepOnId}>Keep screen on</span>
|
||||
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
|
||||
<Toggle
|
||||
checked={keepScreenOn.on}
|
||||
onCheckedChange={keepScreenOn.setOn}
|
||||
aria-labelledby={keepOnId}
|
||||
disabled={!keepScreenOn.supported}
|
||||
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
|
||||
/>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Text size"
|
||||
@@ -348,6 +355,16 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
|
||||
className={styles.textSize}
|
||||
/>
|
||||
</header>
|
||||
{!keepScreenOn.supported && (
|
||||
<p id={keepOnHintId} className={styles.keepOnHint}>
|
||||
This browser can't keep the screen on, so it may dim while you cook.
|
||||
</p>
|
||||
)}
|
||||
{keepScreenOn.failure !== null && (
|
||||
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
|
||||
Couldn't keep the screen on: {keepScreenOn.failure}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{failure !== null && (
|
||||
<ErrorBanner onRetry={textSize.retry}>
|
||||
{textSizeFailureText[failure.action]}: {failure.message}
|
||||
|
||||
@@ -116,6 +116,45 @@ function currentStep() {
|
||||
return current[0]!.textContent
|
||||
}
|
||||
|
||||
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
|
||||
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
|
||||
|
||||
// letGo releases a lock as the browser does, telling its listeners so.
|
||||
function letGo(sentinel: FakeSentinel) {
|
||||
if (sentinel.released) return
|
||||
sentinel.released = true
|
||||
sentinel.dispatchEvent(new Event('release'))
|
||||
}
|
||||
|
||||
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
|
||||
// is granted at once, or, given hold, only when the test calls grant.
|
||||
function stubWakeLock({ hold = false } = {}) {
|
||||
const sentinels: FakeSentinel[] = []
|
||||
const pending: (() => void)[] = []
|
||||
const request = vi.fn((type: string) => {
|
||||
expect(type).toBe('screen')
|
||||
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
|
||||
released: false,
|
||||
release: vi.fn(() => {
|
||||
letGo(sentinel)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
})
|
||||
sentinels.push(sentinel)
|
||||
if (!hold) return Promise.resolve(sentinel)
|
||||
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
|
||||
})
|
||||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
|
||||
return { request, sentinels, grant: () => pending.shift()!() }
|
||||
}
|
||||
|
||||
// setVisibility shows or hides the page as the browser would, telling the
|
||||
// page so.
|
||||
function setVisibility(state: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(1180)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
@@ -126,11 +165,14 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
Reflect.deleteProperty(navigator, 'wakeLock')
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Kitchen reading mode', () => {
|
||||
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
||||
stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
|
||||
@@ -379,4 +421,154 @@ describe('Kitchen reading mode', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Keep screen on', () => {
|
||||
it('holds a screen wake lock while on and releases it when turned off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeEnabled()
|
||||
expect(wakeLock.request).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
|
||||
// On again: a fresh lock.
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the lock when the screen is left', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.unmount()
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a lock granted only after the screen was left', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Hidden, the browser lets the lock go; nothing is asked while hidden.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => setVisibility('visible'))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Visible again with the lock still held: no second lock.
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.click(keepOn)
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
||||
|
||||
act(() => letGo(wakeLock.sentinels[0]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
await act(async () => {})
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// The fresh lock is watched too.
|
||||
act(() => letGo(wakeLock.sentinels[1]!))
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
|
||||
expect(wakeLock.sentinels[2]!.released).toBe(false)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
|
||||
const wakeLock = stubWakeLock({ hold: true })
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Hidden and shown again before the first lock is granted, which the
|
||||
// browser has let go by the time it is.
|
||||
act(() => setVisibility('hidden'))
|
||||
act(() => setVisibility('visible'))
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
||||
letGo(wakeLock.sentinels[0]!)
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => wakeLock.grant())
|
||||
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
||||
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('reports a lock the browser refuses and turns the switch back off', async () => {
|
||||
const wakeLock = stubWakeLock()
|
||||
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
fireEvent.click(keepOn)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
|
||||
)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
||||
expect(keepOn).toBeDisabled()
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
expect(keepOn).toHaveAccessibleDescription(
|
||||
"This browser can't keep the screen on, so it may dim while you cook.",
|
||||
)
|
||||
fireEvent.click(keepOn)
|
||||
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
|
||||
// is on, and a setter. It starts off on each visit.
|
||||
// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is
|
||||
// on, setOn, whether this browser can keep the screen on at all, and why the
|
||||
// screen could not be kept on, if it could not.
|
||||
export type KeepScreenOn = {
|
||||
on: boolean
|
||||
setOn: (on: boolean) => void
|
||||
supported: boolean
|
||||
failure: string | null
|
||||
}
|
||||
|
||||
// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only
|
||||
// then. It starts off on each visit. The browser lets the lock go whenever
|
||||
// the page is hidden, so it is asked for again each time the page is visible
|
||||
// while the switch is on. Turning the switch off or leaving the screen
|
||||
// releases it, including a lock still being granted as the screen is left.
|
||||
//
|
||||
// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
|
||||
// the switch's state; nothing keeps the screen awake yet.
|
||||
export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
|
||||
return useState(false)
|
||||
// A browser without the Screen Wake Lock API (or a page not served over
|
||||
// HTTPS) cannot keep the screen on: supported is false and the switch must
|
||||
// not be offered as working. A request the browser refuses turns the switch
|
||||
// back off and is reported as failure; turning it on again asks again.
|
||||
export function useKeepScreenOn(): KeepScreenOn {
|
||||
const [on, setOnState] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const supported = 'wakeLock' in navigator
|
||||
|
||||
useEffect(() => {
|
||||
if (!on) return
|
||||
const wakeLock = navigator.wakeLock
|
||||
let sentinel: WakeLockSentinel | null = null
|
||||
let requesting = false
|
||||
let left = false
|
||||
|
||||
// The browser may let a lock go while the switch is on: always when the
|
||||
// page is hidden, and, the specification allows, while it is still
|
||||
// visible. Either way the lock is asked for again at once if the page is
|
||||
// visible, or when it is visible again if not, so the switch never claims
|
||||
// a lock that is not held.
|
||||
function onRelease() {
|
||||
if (!left) acquire()
|
||||
}
|
||||
|
||||
function acquire() {
|
||||
if (document.visibilityState !== 'visible' || requesting) return
|
||||
if (sentinel !== null && !sentinel.released) return
|
||||
requesting = true
|
||||
wakeLock.request('screen').then(
|
||||
(granted) => {
|
||||
requesting = false
|
||||
if (left) {
|
||||
void granted.release()
|
||||
return
|
||||
}
|
||||
sentinel = granted
|
||||
granted.addEventListener('release', onRelease)
|
||||
// While the request was pending the page may have been hidden and
|
||||
// shown again (that visibilitychange was ignored) and the lock let
|
||||
// go before it could be listened for, so check again now.
|
||||
acquire()
|
||||
},
|
||||
(err: unknown) => {
|
||||
requesting = false
|
||||
if (left) return
|
||||
setFailure(String(err))
|
||||
setOnState(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
acquire()
|
||||
document.addEventListener('visibilitychange', acquire)
|
||||
return () => {
|
||||
left = true
|
||||
document.removeEventListener('visibilitychange', acquire)
|
||||
if (sentinel !== null) void sentinel.release()
|
||||
}
|
||||
}, [on])
|
||||
|
||||
function setOn(next: boolean) {
|
||||
if (next) setFailure(null)
|
||||
setOnState(next)
|
||||
}
|
||||
|
||||
return { on, setOn, supported, failure }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user