FIX - make phone input and overlays CEF safe

This commit is contained in:
DerEchteAlec
2026-08-12 18:59:33 +02:00
parent ff7e00596b
commit b54839ee74
24 changed files with 730 additions and 112 deletions
+10
View File
@@ -5,6 +5,7 @@ import {
createDefaultHomeLayout,
deleteHomePage,
HOME_GRID_PAGE_SIZE,
homeKeyboardTarget,
MAX_HOME_GRID_PAGES,
moveHomeApp,
parseHomeLayout,
@@ -134,6 +135,15 @@ describe('home layout', () => {
expect(moved.grid[4]).toBe('notes')
})
it('provides bounded keyboard reorder targets without wrapping rows', () => {
expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2)
expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull()
expect(homeKeyboardTarget(defaults, 'grid', 0, 'up')).toBeNull()
expect(homeKeyboardTarget(defaults, 'grid', 0, 'down')).toBe(4)
expect(homeKeyboardTarget(defaults, 'dock', 1, 'left')).toBe(0)
expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull()
})
it('shifts occupied grid slots instead of replacing their apps', () => {
const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0)
expect(reordered.grid.slice(0, 5)).toEqual([
+30
View File
@@ -1,6 +1,8 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
import type { ReorderDirection } from '@/utils/keyboard'
export const HOME_DOCK_CAPACITY = 4
export const HOME_GRID_COLUMNS = 4
export const HOME_GRID_PAGE_SIZE = 20
export const MAX_HOME_GRID_PAGES = 5
@@ -304,3 +306,31 @@ export function moveHomeApp(
source[sourceIndex] = insertIntoSlot(target, targetIndex, appId)
return next
}
export function homeKeyboardTarget(
layout: HomeLayout,
area: HomeArea,
sourceIndex: number,
direction: ReorderDirection,
): number | null {
const source = layout[area]
if (!source[sourceIndex]) return null
if (area === 'dock') {
if (direction !== 'left' && direction !== 'right') return null
const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1)
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
}
const column = sourceIndex % HOME_GRID_COLUMNS
if (direction === 'left' && column === 0) return null
if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null
const deltas: Record<ReorderDirection, number> = {
down: HOME_GRID_COLUMNS,
left: -1,
right: 1,
up: -HOME_GRID_COLUMNS,
}
const targetIndex = sourceIndex + deltas[direction]
return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest'
import {
consumeEscape,
handleEnterAction,
reorderDirectionFromKeyboard,
} from '@/utils/keyboard'
describe('keyboard interaction', () => {
it('does not submit while an IME composition is active', () => {
const action = vi.fn()
const preventDefault = vi.fn()
expect(
handleEnterAction({ isComposing: true, preventDefault }, action),
).toBe(false)
expect(action).not.toHaveBeenCalled()
expect(preventDefault).not.toHaveBeenCalled()
})
it('prevents the completed Enter key and runs its action once', () => {
const action = vi.fn()
const preventDefault = vi.fn()
expect(
handleEnterAction({ isComposing: false, preventDefault }, action),
).toBe(true)
expect(preventDefault).toHaveBeenCalledOnce()
expect(action).toHaveBeenCalledOnce()
})
it('consumes only an unhandled Escape outside IME composition', () => {
const preventDefault = vi.fn()
const stopImmediatePropagation = vi.fn()
expect(
consumeEscape({
defaultPrevented: false,
isComposing: false,
key: 'Escape',
preventDefault,
stopImmediatePropagation,
}),
).toBe(true)
expect(preventDefault).toHaveBeenCalledOnce()
expect(stopImmediatePropagation).toHaveBeenCalledOnce()
expect(
consumeEscape({
defaultPrevented: false,
isComposing: true,
key: 'Escape',
preventDefault,
stopImmediatePropagation,
}),
).toBe(false)
})
it('blocks a second Escape owner on the same event target', () => {
const target = new EventTarget()
const rootHandler = vi.fn()
target.addEventListener('keydown', (event) => {
consumeEscape(event as KeyboardEvent)
})
target.addEventListener('keydown', rootHandler)
const event = new Event('keydown', { cancelable: true })
Object.defineProperties(event, {
isComposing: { value: false },
key: { value: 'Escape' },
})
target.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(rootHandler).not.toHaveBeenCalled()
})
it('maps only unmodified arrow keys to reorder directions', () => {
expect(
reorderDirectionFromKeyboard({
altKey: false,
ctrlKey: false,
isComposing: false,
key: 'ArrowLeft',
metaKey: false,
}),
).toBe('left')
expect(
reorderDirectionFromKeyboard({
altKey: false,
ctrlKey: true,
isComposing: false,
key: 'ArrowLeft',
metaKey: false,
}),
).toBeNull()
})
})
+47
View File
@@ -0,0 +1,47 @@
export type ReorderDirection = 'down' | 'left' | 'right' | 'up'
export function consumeEscape(
event: Pick<
KeyboardEvent,
| 'defaultPrevented'
| 'isComposing'
| 'key'
| 'preventDefault'
| 'stopImmediatePropagation'
>,
): boolean {
if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented) {
return false
}
event.preventDefault()
event.stopImmediatePropagation()
return true
}
export function handleEnterAction(
event: Pick<KeyboardEvent, 'isComposing' | 'preventDefault'>,
action: () => unknown,
): boolean {
if (event.isComposing) return false
event.preventDefault()
void action()
return true
}
export function reorderDirectionFromKeyboard(
event: Pick<
KeyboardEvent,
'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey'
>,
): ReorderDirection | null {
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) {
return null
}
const directions: Partial<Record<string, ReorderDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
}
return directions[event.key] ?? null
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { musicEscapeLayer } from '@/utils/musicEscape'
const closedState = {
actionMenuOpened: false,
activeSheet: false,
addMenuOpened: false,
confirmDeletePlaylist: false,
confirmRemoveTrack: false,
playerOpened: false,
}
describe('music Escape ownership', () => {
it('owns Escape while either music popover is open', () => {
expect(
musicEscapeLayer({ ...closedState, addMenuOpened: true }),
).toBe('menu')
expect(
musicEscapeLayer({ ...closedState, actionMenuOpened: true }),
).toBe('menu')
})
it('keeps a real form sheet above menus and the player', () => {
expect(
musicEscapeLayer({
...closedState,
activeSheet: true,
addMenuOpened: true,
playerOpened: true,
}),
).toBe('sheet')
})
it('does not claim Escape with no music overlay open', () => {
expect(musicEscapeLayer(closedState)).toBeNull()
})
})
+22
View File
@@ -0,0 +1,22 @@
export type MusicEscapeLayer =
| 'delete-playlist-confirmation'
| 'menu'
| 'player'
| 'remove-track-confirmation'
| 'sheet'
export function musicEscapeLayer(state: {
actionMenuOpened: boolean
activeSheet: boolean
addMenuOpened: boolean
confirmDeletePlaylist: boolean
confirmRemoveTrack: boolean
playerOpened: boolean
}): MusicEscapeLayer | null {
if (state.confirmRemoveTrack) return 'remove-track-confirmation'
if (state.confirmDeletePlaylist) return 'delete-playlist-confirmation'
if (state.activeSheet) return 'sheet'
if (state.addMenuOpened || state.actionMenuOpened) return 'menu'
if (state.playerOpened) return 'player'
return null
}
+16
View File
@@ -9,6 +9,7 @@ import {
removeWidget,
resizeWidget,
widgetOccupiedCells,
widgetKeyboardTarget,
} from '@/utils/widgetLayout'
describe('widget layout', () => {
@@ -47,6 +48,21 @@ describe('widget layout', () => {
expect(next.instances).toHaveLength(layout.instances.length)
})
it('provides bounded keyboard targets for each widget size', () => {
const layout = createDefaultWidgetLayout()
const clock = layout.instances.find(
(instance) => instance.id === 'home-clock',
)!
const music = layout.instances.find(
(instance) => instance.id === 'home-music',
)!
expect(widgetKeyboardTarget(clock, 'right')).toEqual({ column: 1, row: 0 })
expect(widgetKeyboardTarget(clock, 'up')).toBeNull()
expect(widgetKeyboardTarget(music, 'right')).toBeNull()
expect(widgetKeyboardTarget(music, 'down')).toEqual({ column: 0, row: 3 })
})
it('allows a small widget in the center with app cells on both sides', () => {
const layout = createDefaultWidgetLayout()
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
+27
View File
@@ -6,6 +6,7 @@ import type {
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import type { ReorderDirection } from '@/utils/keyboard'
export const WIDGET_GRID_COLUMNS = 4
export const WIDGET_HOME_ROWS = 5
@@ -296,6 +297,32 @@ export function moveWidget(
return { instances: placed, version: 1 }
}
export function widgetKeyboardTarget(
instance: WidgetInstance,
direction: ReorderDirection,
): { column: number; row: number } | null {
const span = WIDGET_SPANS[instance.size]
const maximumColumn = WIDGET_GRID_COLUMNS - span.columns
const maximumRow = rowsForPage(instance.page) - span.rows
const target = {
column:
instance.column +
(direction === 'left' ? -1 : direction === 'right' ? 1 : 0),
row:
instance.row +
(direction === 'up' ? -1 : direction === 'down' ? 1 : 0),
}
if (
target.column < 0 ||
target.column > maximumColumn ||
target.row < 0 ||
target.row > maximumRow
) {
return null
}
return target
}
export function resizeWidget(
layout: WidgetLayout,
id: string,