mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
FIX - make phone input and overlays CEF safe
This commit is contained in:
@@ -12,6 +12,10 @@ import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
reorderDirectionFromKeyboard,
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -33,6 +37,7 @@ const emit = defineEmits<{
|
||||
dragstart: [event: PointerEvent]
|
||||
edit: []
|
||||
remove: []
|
||||
reorder: [direction: ReorderDirection]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
@@ -65,6 +70,8 @@ const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let calendarTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
watch(
|
||||
() => props.app.iconImage,
|
||||
@@ -135,6 +142,9 @@ function clearHold(): void {
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (props.compact || event.button !== 0) return
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
@@ -173,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void {
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', cancelPointerDrag)
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
if (isDragging.value) {
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
releasePointerCapture()
|
||||
}
|
||||
|
||||
function cancelPointerDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
const wasDragging = isDragging.value
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
releasePointerCapture()
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelPointerDrag)
|
||||
function releasePointerCapture(): void {
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
pointerTarget.hasPointerCapture(pointerId)
|
||||
) {
|
||||
pointerTarget.releasePointerCapture(pointerId)
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.editMode) return
|
||||
const direction = reorderDirectionFromKeyboard(event)
|
||||
if (!direction) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('reorder', direction)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -214,7 +237,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
|
||||
removeDragListeners()
|
||||
releasePointerCapture()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -234,10 +257,15 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
:aria-label="getPhoneAppLabel(app, phone.t)"
|
||||
:aria-disabled="!app.route"
|
||||
:aria-keyshortcuts="
|
||||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||||
"
|
||||
@click="launch"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointercancel="cancelPointerDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@lostpointercapture="cancelPointerDrag"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
|
||||
@@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function closeFromEscape(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') opened.value = false
|
||||
if (event.key !== 'Escape' || !opened.value) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
opened.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,6 +31,10 @@ import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetInstance } from '@/types/widgets'
|
||||
import {
|
||||
reorderDirectionFromKeyboard,
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
@@ -50,6 +54,7 @@ const emit = defineEmits<{
|
||||
dragstart: [event: PointerEvent]
|
||||
menu: []
|
||||
remove: []
|
||||
reorder: [direction: ReorderDirection]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
@@ -68,6 +73,8 @@ let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
const weatherIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
@@ -173,6 +180,9 @@ function onPointerDown(event: PointerEvent): void {
|
||||
) {
|
||||
return
|
||||
}
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
@@ -210,35 +220,48 @@ function beginDrag(event: PointerEvent): void {
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', cancelDrag)
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
if (isDragging.value) {
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
releasePointerCapture()
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
const wasDragging = isDragging.value
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
releasePointerCapture()
|
||||
if (wasDragging) emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelDrag)
|
||||
function releasePointerCapture(): void {
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
pointerTarget.hasPointerCapture(pointerId)
|
||||
) {
|
||||
pointerTarget.releasePointerCapture(pointerId)
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.editMode) return
|
||||
const direction = reorderDirectionFromKeyboard(event)
|
||||
if (!direction) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('reorder', direction)
|
||||
}
|
||||
|
||||
function openWidget(): void {
|
||||
@@ -276,7 +299,7 @@ async function messageContact(phoneNumber: string): Promise<void> {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
removeDragListeners()
|
||||
releasePointerCapture()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -300,9 +323,14 @@ onBeforeUnmount(() => {
|
||||
:class="`home-widget--${instance.kind}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-keyshortcuts="
|
||||
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
|
||||
"
|
||||
@click="openWidget"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@keydown.enter="openWidget"
|
||||
@lostpointercapture="cancelDrag"
|
||||
@pointercancel="cancelDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -17,6 +18,7 @@ const emit = defineEmits<{
|
||||
dragstart: [id: string, event: PointerEvent]
|
||||
menu: [id: string]
|
||||
remove: [id: string]
|
||||
reorder: [id: string, direction: ReorderDirection]
|
||||
}>()
|
||||
const widgets = useWidgetsStore()
|
||||
const rows = computed(() =>
|
||||
@@ -51,6 +53,7 @@ const instances = computed(() =>
|
||||
@dragstart="emit('dragstart', instance.id, $event)"
|
||||
@menu="emit('menu', instance.id)"
|
||||
@remove="emit('remove', instance.id)"
|
||||
@reorder="emit('reorder', instance.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +41,9 @@ function select(value: string): void {
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
if (!isOpen.value) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,13 +17,16 @@ import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import {
|
||||
deleteHomePage as previewHomePageDelete,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
homeKeyboardTarget,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
type HomeArea,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
deleteWidgetPage as previewWidgetPageDelete,
|
||||
moveWidget as previewWidgetMove,
|
||||
WIDGET_GRID_COLUMNS,
|
||||
widgetKeyboardTarget,
|
||||
widgetOccupiedCells,
|
||||
} from '@/utils/widgetLayout'
|
||||
|
||||
@@ -515,6 +518,14 @@ function stopWidgetDrag(): void {
|
||||
clearWidgetDragPreview()
|
||||
}
|
||||
|
||||
function reorderWidget(id: string, direction: ReorderDirection): void {
|
||||
const instance = widgets.layout.instances.find((widget) => widget.id === id)
|
||||
if (!instance) return
|
||||
const target = widgetKeyboardTarget(instance, direction)
|
||||
if (!target) return
|
||||
widgets.move(id, instance.page, target.column, target.row)
|
||||
}
|
||||
|
||||
function removeWidget(id: string): void {
|
||||
widgets.remove(id)
|
||||
if (widgetActionId.value === id) widgetActionId.value = null
|
||||
@@ -620,6 +631,21 @@ function stopHomeDrag(): void {
|
||||
draggingHomeApp.value = null
|
||||
}
|
||||
|
||||
function reorderHomeApp(
|
||||
area: HomeArea,
|
||||
sourceIndex: number,
|
||||
direction: ReorderDirection,
|
||||
): void {
|
||||
const targetIndex = homeKeyboardTarget(
|
||||
appStore.homeLayout,
|
||||
area,
|
||||
sourceIndex,
|
||||
direction,
|
||||
)
|
||||
if (targetIndex === null) return
|
||||
appStore.moveHomeApp(area, sourceIndex, area, targetIndex)
|
||||
}
|
||||
|
||||
async function addHomePage(): Promise<void> {
|
||||
if (addingHomePage.value) return
|
||||
addingHomePage.value = true
|
||||
@@ -704,6 +730,7 @@ watch(isEditablePage, (visible) => {
|
||||
@dragstart="startWidgetDrag"
|
||||
@menu="openWidgetMenu"
|
||||
@remove="removeWidget"
|
||||
@reorder="reorderWidget"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -725,6 +752,7 @@ watch(isEditablePage, (visible) => {
|
||||
@dragstart="startWidgetDrag"
|
||||
@menu="openWidgetMenu"
|
||||
@remove="removeWidget"
|
||||
@reorder="reorderWidget"
|
||||
/>
|
||||
<TransitionGroup
|
||||
:name="
|
||||
@@ -750,6 +778,7 @@ watch(isEditablePage, (visible) => {
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
@@ -946,6 +975,7 @@ watch(isEditablePage, (visible) => {
|
||||
@dragstart="startHomeDrag('dock', appIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(app.id)"
|
||||
@reorder="reorderHomeApp('dock', appIndex, $event)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
BankingTransactionKind,
|
||||
} from '@/types/banking'
|
||||
import type { PhoneContact } from '@/types/phone'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
|
||||
|
||||
type BankingTab = 'home' | 'activity'
|
||||
@@ -153,7 +154,9 @@ function closeAction(): void {
|
||||
|
||||
function updateTarget(event: Event): void {
|
||||
if (!(event.target instanceof HTMLInputElement)) {
|
||||
console.error('[banking] Phone number input emitted without an input target.')
|
||||
console.error(
|
||||
'[banking] Phone number input emitted without an input target.',
|
||||
)
|
||||
return
|
||||
}
|
||||
target.value = event.target.value
|
||||
@@ -163,7 +166,9 @@ function updateTarget(event: Event): void {
|
||||
function selectContact(contact: PhoneContact): void {
|
||||
target.value = contact.phone_number
|
||||
formError.value = ''
|
||||
void nextTick(() => document.getElementById('banking-transfer-amount')?.focus())
|
||||
void nextTick(() =>
|
||||
document.getElementById('banking-transfer-amount')?.focus(),
|
||||
)
|
||||
}
|
||||
|
||||
function updateAmount(event: Event): void {
|
||||
@@ -237,6 +242,7 @@ function focusableSheetElements(): HTMLElement[] {
|
||||
function handleSheetKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeAction()
|
||||
return
|
||||
}
|
||||
@@ -258,6 +264,15 @@ function handleSheetKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape' || !action.value || event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
closeAction()
|
||||
}
|
||||
|
||||
function errorMessage(code: string): string {
|
||||
return phone.t(`Apps.banking.errors.${code}`) ===
|
||||
`Apps.banking.errors.${code}`
|
||||
@@ -268,9 +283,8 @@ function errorMessage(code: string): string {
|
||||
async function submitAction(): Promise<void> {
|
||||
if (!action.value) return
|
||||
const parsedAmount = Number(amount.value)
|
||||
const phoneNumber = action.value === 'transfer'
|
||||
? normalizePhoneNumber(target.value)
|
||||
: undefined
|
||||
const phoneNumber =
|
||||
action.value === 'transfer' ? normalizePhoneNumber(target.value) : undefined
|
||||
if (
|
||||
!Number.isSafeInteger(parsedAmount) ||
|
||||
parsedAmount <= 0 ||
|
||||
@@ -291,13 +305,17 @@ async function submitAction(): Promise<void> {
|
||||
action.value = null
|
||||
}
|
||||
|
||||
onMounted(() => void banking.load())
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleWindowKeydown)
|
||||
void banking.load()
|
||||
})
|
||||
|
||||
watch(action, async (currentAction) => {
|
||||
if (currentAction) {
|
||||
previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
await nextTick()
|
||||
document.getElementById('banking-transfer-target')?.focus()
|
||||
return
|
||||
@@ -307,6 +325,7 @@ watch(action, async (currentAction) => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleWindowKeydown)
|
||||
if (wheelRefreshTimeout) clearTimeout(wheelRefreshTimeout)
|
||||
previousFocus?.focus()
|
||||
})
|
||||
@@ -379,12 +398,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<strong>{{ formatMoney(banking.overview.bank) }}</strong>
|
||||
<div class="banking-balance__trend">
|
||||
<span>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</span>
|
||||
<span>{{
|
||||
formatMoney(totals.incoming - totals.outgoing, true)
|
||||
}}</span>
|
||||
{{ phone.t('Apps.banking.recentPeriod') }}
|
||||
</div>
|
||||
</k-glass>
|
||||
|
||||
<section class="banking-actions" :aria-label="phone.t('Apps.banking.actions')">
|
||||
<section
|
||||
class="banking-actions"
|
||||
:aria-label="phone.t('Apps.banking.actions')"
|
||||
>
|
||||
<k-glass
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -435,17 +459,27 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<k-list inset strong class="banking-transaction-list">
|
||||
<k-list-item
|
||||
v-for="transaction in banking.overview.transactions.slice(0, 5)"
|
||||
:key="transaction.id"
|
||||
v-for="transaction in banking.overview.transactions.slice(0, 5)"
|
||||
:key="transaction.id"
|
||||
:subtitle="formatDate(transaction.createdAt)"
|
||||
:title="transactionTitle(transaction)"
|
||||
>
|
||||
<template #media>
|
||||
<component :is="transactionIcons[transaction.kind]" :size="17" />
|
||||
<component
|
||||
:is="transactionIcons[transaction.kind]"
|
||||
:size="17"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
|
||||
{{
|
||||
formatMoney(
|
||||
isIncoming(transaction.kind)
|
||||
? transaction.amount
|
||||
: -transaction.amount,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
</b>
|
||||
</template>
|
||||
</k-list-item>
|
||||
@@ -460,13 +494,18 @@ onBeforeUnmount(() => {
|
||||
<template v-else>
|
||||
<section class="banking-activity-hero">
|
||||
<span>{{ phone.t('Apps.banking.activity') }}</span>
|
||||
<strong>{{ formatMoney(totals.incoming - totals.outgoing, true) }}</strong>
|
||||
<strong>{{
|
||||
formatMoney(totals.incoming - totals.outgoing, true)
|
||||
}}</strong>
|
||||
<small>{{ phone.t('Apps.banking.recentPeriod') }}</small>
|
||||
</section>
|
||||
|
||||
<k-card :content-wrap="false" class="banking-card banking-chart-card">
|
||||
<div class="banking-chart-legend">
|
||||
<span><i class="is-incoming"></i>{{ phone.t('Apps.banking.incoming') }}</span>
|
||||
<span
|
||||
><i class="is-incoming"></i
|
||||
>{{ phone.t('Apps.banking.incoming') }}</span
|
||||
>
|
||||
<span><i></i>{{ phone.t('Apps.banking.outgoing') }}</span>
|
||||
</div>
|
||||
<div class="banking-chart">
|
||||
@@ -475,14 +514,19 @@ onBeforeUnmount(() => {
|
||||
:key="day.date.getTime()"
|
||||
class="banking-chart__day"
|
||||
role="img"
|
||||
:aria-label="phone.t('Apps.banking.chartDaySummary', {
|
||||
day: day.label,
|
||||
incoming: formatMoney(day.incoming),
|
||||
outgoing: formatMoney(day.outgoing),
|
||||
})"
|
||||
:aria-label="
|
||||
phone.t('Apps.banking.chartDaySummary', {
|
||||
day: day.label,
|
||||
incoming: formatMoney(day.incoming),
|
||||
outgoing: formatMoney(day.outgoing),
|
||||
})
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<i class="is-incoming" :style="{ height: `${day.incomingHeight}%` }"></i>
|
||||
<i
|
||||
class="is-incoming"
|
||||
:style="{ height: `${day.incomingHeight}%` }"
|
||||
></i>
|
||||
<i :style="{ height: `${day.outgoingHeight}%` }"></i>
|
||||
</div>
|
||||
<span>{{ day.label }}</span>
|
||||
@@ -494,7 +538,10 @@ onBeforeUnmount(() => {
|
||||
<div class="banking-section-title">
|
||||
<h2>{{ phone.t('Apps.banking.allTransactions') }}</h2>
|
||||
</div>
|
||||
<k-card :content-wrap="false" class="banking-card banking-transaction-card">
|
||||
<k-card
|
||||
:content-wrap="false"
|
||||
class="banking-card banking-transaction-card"
|
||||
>
|
||||
<k-list inset strong class="banking-transaction-list">
|
||||
<k-list-item
|
||||
v-for="transaction in banking.overview.transactions"
|
||||
@@ -503,11 +550,21 @@ onBeforeUnmount(() => {
|
||||
:title="transactionTitle(transaction)"
|
||||
>
|
||||
<template #media>
|
||||
<component :is="transactionIcons[transaction.kind]" :size="17" />
|
||||
<component
|
||||
:is="transactionIcons[transaction.kind]"
|
||||
:size="17"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<b :class="{ 'is-incoming': isIncoming(transaction.kind) }">
|
||||
{{ formatMoney(isIncoming(transaction.kind) ? transaction.amount : -transaction.amount, true) }}
|
||||
{{
|
||||
formatMoney(
|
||||
isIncoming(transaction.kind)
|
||||
? transaction.amount
|
||||
: -transaction.amount,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
</b>
|
||||
</template>
|
||||
</k-list-item>
|
||||
@@ -553,7 +610,7 @@ onBeforeUnmount(() => {
|
||||
</k-toolbar-pane>
|
||||
</k-tabbar>
|
||||
|
||||
<k-sheet :opened="Boolean(action)" class="banking-sheet" @backdropclick="closeAction">
|
||||
<k-sheet :opened="Boolean(action)" @backdropclick="closeAction">
|
||||
<section
|
||||
v-if="action"
|
||||
class="banking-sheet__content"
|
||||
@@ -600,7 +657,7 @@ onBeforeUnmount(() => {
|
||||
type="number"
|
||||
:value="amount"
|
||||
@input="updateAmount"
|
||||
@keydown.enter="submitAction"
|
||||
@keydown.enter="handleEnterAction($event, submitAction)"
|
||||
/>
|
||||
</k-list>
|
||||
<div class="banking-contact-picker">
|
||||
|
||||
@@ -94,6 +94,7 @@ import type {
|
||||
CompanySummary,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneMedia } from '@/types/media'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
type CompaniesTab = 'directory' | 'requests' | 'work'
|
||||
@@ -1764,7 +1765,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="phone.t('Apps.companies.requests.replyPlaceholder')"
|
||||
:value="threadDraft"
|
||||
@input="threadDraft = messagebarValue($event)"
|
||||
@keydown.enter.exact.prevent="sendThreadMessage"
|
||||
@keydown.enter.exact="handleEnterAction($event, sendThreadMessage)"
|
||||
>
|
||||
<template #right>
|
||||
<k-toolbar-pane class="ios:h-10">
|
||||
|
||||
@@ -82,6 +82,7 @@ import type {
|
||||
} from '@/types/crewlink'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { easyShareCrewLinkInviteCode } from '@/utils/easyshare'
|
||||
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
@@ -699,7 +700,30 @@ function onCrewLinkMessage(event: MessageEvent): void {
|
||||
if (event.data?.type === 'crewlink:changed') void crew.bootstrap()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (
|
||||
!confirmAction.value &&
|
||||
!sheet.value &&
|
||||
!selectedMember.value &&
|
||||
!selectedPing.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!consumeEscape(event)) return
|
||||
|
||||
if (confirmAction.value) {
|
||||
cancelConfirmation()
|
||||
} else if (sheet.value) {
|
||||
closeSheet()
|
||||
} else if (selectedMember.value) {
|
||||
selectedMember.value = null
|
||||
} else {
|
||||
selectedPing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
await crew.bootstrap()
|
||||
username.value = crew.profile?.username ?? ''
|
||||
openSharedInvite()
|
||||
@@ -713,6 +737,7 @@ onBeforeUnmount(() => {
|
||||
if (liveTimer) window.clearInterval(liveTimer)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (pointerFrame) cancelAnimationFrame(pointerFrame)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
window.removeEventListener('message', onCrewLinkMessage)
|
||||
})
|
||||
</script>
|
||||
@@ -756,7 +781,7 @@ onBeforeUnmount(() => {
|
||||
maxlength="20"
|
||||
outline
|
||||
@input="updateValue('username', $event)"
|
||||
@keydown.enter="createProfile"
|
||||
@keydown.enter="handleEnterAction($event, createProfile)"
|
||||
/>
|
||||
</k-list>
|
||||
<p v-if="formError" class="crewlink-error" role="alert">{{ formError }}</p>
|
||||
@@ -1036,7 +1061,7 @@ onBeforeUnmount(() => {
|
||||
</k-tabbar>
|
||||
</template>
|
||||
|
||||
<k-sheet :opened="Boolean(sheet)" class="crewlink-sheet" @backdropclick="closeSheet">
|
||||
<k-sheet :opened="Boolean(sheet)" @backdropclick="closeSheet">
|
||||
<section v-if="sheet" class="crewlink-sheet__content" role="dialog" aria-modal="true">
|
||||
<k-link component="button" class="crewlink-sheet__close" :link-props="{ type: 'button' }" :aria-label="phone.t('Common.close')" @click="closeSheet"><X /></k-link>
|
||||
|
||||
@@ -1051,7 +1076,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else-if="sheet === 'join-group'">
|
||||
<span class="crewlink-sheet__icon"><UserPlus /></span><h2>{{ t('joinWithCode') }}</h2><p>{{ t('joinWithCodeBody') }}</p>
|
||||
<k-list inset strong class="crewlink-form-list"><k-list-input input-id="crewlink-invite-code" :label="t('inviteCode')" :placeholder="t('inviteCodePlaceholder')" :value="inviteCode" maxlength="8" outline @input="updateValue('inviteCode', $event)" @keydown.enter="joinGroup" /></k-list>
|
||||
<k-list inset strong class="crewlink-form-list"><k-list-input input-id="crewlink-invite-code" :label="t('inviteCode')" :placeholder="t('inviteCodePlaceholder')" :value="inviteCode" maxlength="8" outline @input="updateValue('inviteCode', $event)" @keydown.enter="handleEnterAction($event, joinGroup)" /></k-list>
|
||||
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
|
||||
<k-button large rounded :disabled="crew.isLoading" @click="joinGroup">{{ t('joinCrew') }}</k-button>
|
||||
<template v-if="crew.invitations.length">
|
||||
@@ -1143,13 +1168,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else-if="sheet === 'member' && !selectedMember && crew.profile">
|
||||
<span class="crewlink-sheet__icon"><UserRound /></span><h2>{{ t('editUsername') }}</h2><p>{{ t('editUsernameBody') }}</p>
|
||||
<k-list inset strong class="crewlink-form-list"><k-list-input input-id="crewlink-edit-username" :label="t('username')" :value="username" maxlength="20" outline @input="updateValue('username', $event)" @keydown.enter="saveProfile" /></k-list>
|
||||
<k-list inset strong class="crewlink-form-list"><k-list-input input-id="crewlink-edit-username" :label="t('username')" :value="username" maxlength="20" outline @input="updateValue('username', $event)" @keydown.enter="handleEnterAction($event, saveProfile)" /></k-list>
|
||||
<p v-if="formError" class="crewlink-error">{{ formError }}</p><k-button large rounded @click="saveProfile">{{ phone.t('Common.save') }}</k-button>
|
||||
</template>
|
||||
</section>
|
||||
</k-sheet>
|
||||
|
||||
<k-sheet :opened="Boolean(selectedMember && !sheet)" class="crewlink-sheet" @backdropclick="selectedMember = null">
|
||||
<k-sheet :opened="Boolean(selectedMember && !sheet)" @backdropclick="selectedMember = null">
|
||||
<section v-if="selectedMember" class="crewlink-sheet__content crewlink-member-preview">
|
||||
<k-link component="button" class="crewlink-sheet__close" :link-props="{ type: 'button' }" @click="selectedMember = null"><X /></k-link>
|
||||
<span class="crewlink-sheet__avatar" :style="{ '--crew': activeColour }">{{ memberInitials(selectedMember) }}</span><h2>@{{ selectedMember.username }}</h2><p>{{ roleLabel(selectedMember.role) }} · {{ memberStatus(selectedMember) }}</p>
|
||||
@@ -1157,7 +1182,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</k-sheet>
|
||||
|
||||
<k-sheet :opened="Boolean(selectedPing)" class="crewlink-sheet" @backdropclick="selectedPing = null">
|
||||
<k-sheet :opened="Boolean(selectedPing)" @backdropclick="selectedPing = null">
|
||||
<section v-if="selectedPing" class="crewlink-sheet__content crewlink-member-preview">
|
||||
<k-link component="button" class="crewlink-sheet__close" :link-props="{ type: 'button' }" @click="selectedPing = null"><X /></k-link>
|
||||
<span class="crewlink-sheet__icon" :style="{ background: pingColours[selectedPing.type] }"><component :is="pingIcons[selectedPing.type]" /></span><small>{{ t(`pingTypes.${selectedPing.type}`) }}</small><h2>{{ selectedPing.label }}</h2><p>{{ t('sharedBy', { username: selectedPing.creatorUsername }) }} · {{ expiresIn(selectedPing.expiresAt) }}</p>
|
||||
|
||||
@@ -68,6 +68,7 @@ import type { EasySharePayload } from '@/types/easyshare'
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
|
||||
const VOICE_MAX_DURATION_MS = 60_000
|
||||
const VOICE_MAX_BYTES = 270_000
|
||||
@@ -2332,7 +2333,7 @@ onBeforeUnmount(() => {
|
||||
clear-button
|
||||
@input="setIdentifier"
|
||||
@clear="identifier = ''"
|
||||
@keydown.enter.prevent="requestStart()"
|
||||
@keydown.enter="handleEnterAction($event, requestStart)"
|
||||
/></k-list>
|
||||
<k-button
|
||||
large
|
||||
@@ -2601,7 +2602,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="t('message')"
|
||||
:colors="darkMessagebarColors"
|
||||
@input="setDraft"
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
@keydown.enter.exact="handleEnterAction($event, sendText)"
|
||||
>
|
||||
<template #left
|
||||
><k-link
|
||||
|
||||
@@ -279,9 +279,17 @@ function moveMediaPreview(direction: number): void {
|
||||
|
||||
function handleMediaPreviewKeydown(event: KeyboardEvent): void {
|
||||
if (!mediaPreview.value) return
|
||||
if (event.key === 'Escape') closeMediaPreview()
|
||||
if (event.key === 'ArrowLeft') moveMediaPreview(-1)
|
||||
if (event.key === 'ArrowRight') moveMediaPreview(1)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
closeMediaPreview()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
moveMediaPreview(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
moveMediaPreview(1)
|
||||
}
|
||||
}
|
||||
|
||||
function toast(path: string): void {
|
||||
@@ -731,11 +739,11 @@ watch(
|
||||
onBeforeUnmount(() => {
|
||||
if (exploreSearchTimer !== undefined) window.clearTimeout(exploreSearchTimer)
|
||||
if (networkSearchTimer !== undefined) window.clearTimeout(networkSearchTimer)
|
||||
window.removeEventListener('keydown', handleMediaPreviewKeydown)
|
||||
window.removeEventListener('keydown', handleMediaPreviewKeydown, true)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', handleMediaPreviewKeydown)
|
||||
window.addEventListener('keydown', handleMediaPreviewKeydown, true)
|
||||
const selection =
|
||||
messageMedia.consumeMany<ComposerContext>('feather:composer')
|
||||
const profileSelection =
|
||||
@@ -4929,4 +4937,87 @@ onMounted(async () => {
|
||||
color: inherit;
|
||||
background: var(--feather-panel);
|
||||
}
|
||||
@supports not (color: color-mix(in srgb, white, black)) {
|
||||
.feather-navbar {
|
||||
--k-navbar-bg-color: rgb(255 255 255 / 91%);
|
||||
border-bottom-color: rgb(127 127 127 / 18%);
|
||||
}
|
||||
:global(.dark) .feather-navbar {
|
||||
--k-navbar-bg-color: rgb(9 13 18 / 91%);
|
||||
}
|
||||
.dark.feather-app .feather-navbar {
|
||||
--k-navbar-bg-color: rgb(0 0 0 / 90%);
|
||||
background: rgb(0 0 0 / 88%);
|
||||
}
|
||||
.feather-app--active .feather-navbar {
|
||||
--k-navbar-bg-color: rgb(18 23 27 / 91%);
|
||||
background: rgb(18 23 27 / 88%);
|
||||
}
|
||||
.feather-app--active.feather-app--light .feather-navbar {
|
||||
--k-navbar-bg-color: rgb(251 251 246 / 91%);
|
||||
background: rgb(251 251 246 / 88%);
|
||||
}
|
||||
.feather-feed-tabs,
|
||||
.feather-explore-head,
|
||||
.feather-activity-head,
|
||||
.feather-trend,
|
||||
.feather-composer__tools,
|
||||
.feather-section-title,
|
||||
.feather-profile,
|
||||
.feather-profile-tabs,
|
||||
.feather-activity,
|
||||
.feather-person {
|
||||
border-color: rgb(127 127 127 / 18%);
|
||||
}
|
||||
.feather-trend:active,
|
||||
.feather-profile__stats button,
|
||||
.feather-report select,
|
||||
.feather-report textarea,
|
||||
.feather-app--active .feather-explore-search :deep(form) {
|
||||
background: rgb(127 127 127 / 7%);
|
||||
}
|
||||
.feather-profile__stats button:active,
|
||||
.feather-thread-reply__comment-target,
|
||||
.feather-thread-title span,
|
||||
.feather-app--active .feather-profile__stats span,
|
||||
.feather-composer-media > header > span,
|
||||
.feather-composer-media > header > b,
|
||||
.feather-app--active .feather-follow-button--pending:hover,
|
||||
.feather-app--active :deep(.feather-follow:hover),
|
||||
.feather-composer-media__actions button:not(:disabled):hover {
|
||||
background: rgb(29 155 240 / 13%);
|
||||
}
|
||||
.feather-report select,
|
||||
.feather-report textarea,
|
||||
.feather-app--active .feather-follow-button--following {
|
||||
border-color: rgb(127 127 127 / 24%);
|
||||
}
|
||||
.feather-app--active :deep(.feather-post-glass),
|
||||
.feather-network-list,
|
||||
.feather-app--active .feather-thread-reply,
|
||||
.feather-profile-suggestion,
|
||||
.feather-edit__identity,
|
||||
.feather-edit__photo,
|
||||
.feather-edit__fields,
|
||||
.feather-connections__tabs,
|
||||
.feather-connections__list {
|
||||
background: var(--feather-panel);
|
||||
}
|
||||
.feather-network-person__bio {
|
||||
color: var(--feather-muted);
|
||||
}
|
||||
.feather-network-person__avatar .feather-avatar,
|
||||
.feather-app--active .feather-follow-button--pending,
|
||||
.feather-app--active .feather-profile__actions :deep(.k-button),
|
||||
.feather-edit__photo-actions :deep(.k-button) {
|
||||
border-color: var(--feather-blue);
|
||||
}
|
||||
.feather-compose-fab,
|
||||
.feather-edit__avatar {
|
||||
border-color: #70c5fa;
|
||||
}
|
||||
.feather-connections__remove {
|
||||
border-color: #f04f65;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,6 +85,7 @@ import type { PhoneMedia } from '@/types/media'
|
||||
import type { EasySharePayload } from '@/types/easyshare'
|
||||
import type { GifSearchResult, SmsAttachmentType } from '@/types/messages'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
|
||||
type FlareTab = 'discover' | 'explore' | 'likes' | 'matches' | 'profile'
|
||||
type ExploreMode = 'all' | 'dates' | 'friends' | 'longTerm'
|
||||
@@ -1145,7 +1146,7 @@ onBeforeUnmount(() => {
|
||||
:value="draft"
|
||||
:disabled="flare.sending"
|
||||
@input="draft = eventValue($event)"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterAction($event, sendMessage)"
|
||||
>
|
||||
<template #left>
|
||||
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
|
||||
@@ -1861,7 +1862,7 @@ onBeforeUnmount(() => {
|
||||
:aria-modal="choiceOpened ? 'true' : undefined"
|
||||
aria-labelledby="flare-choice-sheet-title"
|
||||
:inert="!choiceOpened"
|
||||
@keydown.esc="closeChoice"
|
||||
@keydown.esc.stop.prevent="closeChoice"
|
||||
>
|
||||
<span class="flare-choice-sheet__grabber" aria-hidden="true" />
|
||||
<header class="flare-choice-sheet__header">
|
||||
@@ -3097,4 +3098,19 @@ onBeforeUnmount(() => {
|
||||
:global(.phone-app.dark .flare-action) {
|
||||
box-shadow: none;
|
||||
}
|
||||
@supports not (color: color-mix(in srgb, white, black)) {
|
||||
.flare-navbar {
|
||||
border-bottom-color: rgb(127 127 127 / 18%);
|
||||
}
|
||||
.flare-photo-slot {
|
||||
background: rgb(127 127 127 / 18%);
|
||||
}
|
||||
.flare-photo-grid :deep(.flare-photo-add) {
|
||||
border-color: rgb(127 127 127 / 44%);
|
||||
background: var(--flare-surface);
|
||||
}
|
||||
.flare-choice-sheet__grabber {
|
||||
background: rgb(127 127 127 / 38%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useMapStore } from '@/stores/map'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MapMarker, MapMarkerColor } from '@/types/map'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
|
||||
@@ -656,7 +657,7 @@ onBeforeUnmount(() => {
|
||||
maxlength="40"
|
||||
outline
|
||||
@input="updateMarkerLabel"
|
||||
@keydown.enter="saveMarker"
|
||||
@keydown.enter="handleEnterAction($event, saveMarker)"
|
||||
/>
|
||||
</k-list>
|
||||
<span class="map-marker-sheet__label">{{
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
import { handleEnterAction } from '@/utils/keyboard'
|
||||
import { sortContactsByMessageRecency } from '@/utils/messages'
|
||||
import type {
|
||||
GifSearchResult,
|
||||
@@ -1496,7 +1497,7 @@ onBeforeUnmount(() => {
|
||||
:value="draft"
|
||||
:disabled="sending"
|
||||
@input="draft = eventValue($event)"
|
||||
@keydown.enter.exact.prevent="sendTextMessage"
|
||||
@keydown.enter.exact="handleEnterAction($event, sendTextMessage)"
|
||||
>
|
||||
<template #left>
|
||||
<k-toolbar-pane class="ios:h-10 messages-messagebar__tools">
|
||||
|
||||
@@ -53,6 +53,8 @@ import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MusicPlaylist, MusicTrack } from '@/types/music'
|
||||
import { easyShareMusicTarget } from '@/utils/easyshare'
|
||||
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
|
||||
import { musicEscapeLayer } from '@/utils/musicEscape'
|
||||
|
||||
type MusicTab = 'library' | 'playlists' | 'search'
|
||||
type MusicSheet =
|
||||
@@ -498,6 +500,31 @@ function updateVolume(event: Event): void {
|
||||
music.setVolume(Number(eventValue(event)) / 100)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
const layer = musicEscapeLayer({
|
||||
actionMenuOpened: actionMenuOpened.value,
|
||||
activeSheet: Boolean(activeSheet.value),
|
||||
addMenuOpened: addMenuOpened.value,
|
||||
confirmDeletePlaylist: confirmDeletePlaylist.value,
|
||||
confirmRemoveTrack: confirmRemoveTrack.value,
|
||||
playerOpened: playerOpened.value,
|
||||
})
|
||||
if (!layer) return
|
||||
if (!consumeEscape(event)) return
|
||||
|
||||
if (layer === 'remove-track-confirmation') {
|
||||
cancelRemoveTrack()
|
||||
} else if (layer === 'delete-playlist-confirmation') {
|
||||
confirmDeletePlaylist.value = false
|
||||
} else if (layer === 'sheet') {
|
||||
closeSheet()
|
||||
} else if (layer === 'menu') {
|
||||
dismissMenus()
|
||||
} else if (layer === 'player') {
|
||||
playerOpened.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => music.playlists,
|
||||
() => {
|
||||
@@ -510,6 +537,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
await music.load()
|
||||
const target = easyShareMusicTarget(
|
||||
route.query.easyShareKind,
|
||||
@@ -531,6 +559,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1061,12 +1090,12 @@ onBeforeUnmount(() => {
|
||||
</k-list>
|
||||
</section>
|
||||
|
||||
<k-sheet
|
||||
:opened="Boolean(activeSheet)"
|
||||
class="music-form-sheet"
|
||||
@backdropclick="closeSheet"
|
||||
>
|
||||
<section class="music-sheet-content">
|
||||
<div class="music-form-sheet">
|
||||
<k-sheet
|
||||
:opened="Boolean(activeSheet)"
|
||||
@backdropclick="closeSheet"
|
||||
>
|
||||
<section class="music-sheet-content">
|
||||
<header>
|
||||
<k-link component="button" @click="closeSheet">{{
|
||||
phone.t(
|
||||
@@ -1089,7 +1118,7 @@ onBeforeUnmount(() => {
|
||||
type="url"
|
||||
:value="youtubeUrl"
|
||||
@input="youtubeUrl = eventValue($event)"
|
||||
@keydown.enter="submitYouTube"
|
||||
@keydown.enter="handleEnterAction($event, submitYouTube)"
|
||||
/>
|
||||
<k-list-input
|
||||
:label="phone.t('Apps.music.youtubeTitle')"
|
||||
@@ -1098,7 +1127,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="phone.t('Apps.music.youtubeTitlePlaceholder')"
|
||||
:value="youtubeTitle"
|
||||
@input="youtubeTitle = eventValue($event)"
|
||||
@keydown.enter="submitYouTube"
|
||||
@keydown.enter="handleEnterAction($event, submitYouTube)"
|
||||
/>
|
||||
<k-list-input
|
||||
:label="phone.t('Apps.music.youtubeArtist')"
|
||||
@@ -1107,7 +1136,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="phone.t('Apps.music.youtubeArtistPlaceholder')"
|
||||
:value="youtubeArtist"
|
||||
@input="youtubeArtist = eventValue($event)"
|
||||
@keydown.enter="submitYouTube"
|
||||
@keydown.enter="handleEnterAction($event, submitYouTube)"
|
||||
/>
|
||||
</k-list>
|
||||
<p v-if="music.error" class="music-form-error" role="alert">
|
||||
@@ -1256,7 +1285,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="phone.t('Apps.music.playlistPlaceholder')"
|
||||
:value="playlistName"
|
||||
@input="playlistName = eventValue($event)"
|
||||
@keydown.enter="submitPlaylist"
|
||||
@keydown.enter="handleEnterAction($event, submitPlaylist)"
|
||||
/>
|
||||
</k-list>
|
||||
<p v-if="music.error" class="music-form-error" role="alert">
|
||||
@@ -1272,15 +1301,16 @@ onBeforeUnmount(() => {
|
||||
<template v-else>{{ phone.t('Common.save') }}</template>
|
||||
</k-button>
|
||||
</template>
|
||||
</section>
|
||||
</k-sheet>
|
||||
</section>
|
||||
</k-sheet>
|
||||
</div>
|
||||
|
||||
<k-sheet
|
||||
:opened="playerOpened"
|
||||
class="music-player-sheet"
|
||||
@backdropclick="playerOpened = false"
|
||||
>
|
||||
<section v-if="music.currentTrack" class="music-player">
|
||||
<div class="music-player-sheet">
|
||||
<k-sheet
|
||||
:opened="playerOpened"
|
||||
@backdropclick="playerOpened = false"
|
||||
>
|
||||
<section v-if="music.currentTrack" class="music-player">
|
||||
<header>
|
||||
<k-link
|
||||
component="button"
|
||||
@@ -1383,8 +1413,9 @@ onBeforeUnmount(() => {
|
||||
<p v-if="music.playbackError" class="music-player-error">
|
||||
{{ phone.t('Apps.music.errors.playback_failed') }}
|
||||
</p>
|
||||
</section>
|
||||
</k-sheet>
|
||||
</section>
|
||||
</k-sheet>
|
||||
</div>
|
||||
|
||||
<k-dialog
|
||||
:opened="confirmRemoveTrack"
|
||||
@@ -1874,6 +1905,7 @@ onBeforeUnmount(() => {
|
||||
.music-form-sheet,
|
||||
.music-player-sheet {
|
||||
--music-accent: #fa2d48;
|
||||
display: contents;
|
||||
color: var(--music-label);
|
||||
}
|
||||
|
||||
@@ -1985,7 +2017,7 @@ onBeforeUnmount(() => {
|
||||
color: #ff453a !important;
|
||||
}
|
||||
|
||||
.music-player-sheet {
|
||||
.music-player-sheet :deep(.k-sheet) {
|
||||
background: rgb(22 22 25 / 96%) !important;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user