mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
FIX - make phone input and overlays CEF safe
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user