ENH - unify phone notifications as popups

Replace the central Konsta notification banner with the shared Sky UI popup across phone and preview surfaces. Add light and dark materials, app icons, timestamps, routed opening, swipe dismissal, and contract coverage.
This commit is contained in:
Leon.Schmidt
2026-08-16 23:32:47 +02:00
parent 102af69e48
commit 49bd7f358f
7 changed files with 292 additions and 52 deletions
@@ -73,6 +73,7 @@ const wrapperStyle = computed<CSSProperties>(() => ({
preview
/>
<PhoneNotifications
:dark="isDarkMode"
:notification="notification"
@close="emit('close')"
@open="emit('open', $event)"
@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneNotifications.vue', import.meta.url),
'utf8',
)
const previewSource = readFileSync(
new URL('./NotificationPhonePreview.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
describe('Phone popup notification contract', () => {
it('uses the shared Sky popup for every central phone notification', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).toContain(
"import { SkyNotification, SkyProvider } from '@/ui'",
)
expect(source).toContain('<SkyProvider')
expect(source).toContain('<SkyNotification')
expect(source).toContain(':title="notification?.title"')
expect(source).toContain(':text="notification?.text"')
expect(source).toContain(':title-right-text="notificationTime"')
expect(previewSource).toContain(':dark="isDarkMode"')
})
it('shows the app icon and opens routed notifications accessibly', () => {
expect(source).toContain('getPhoneApp(props.notification.appId)?.iconImage')
expect(source).toContain('class="phone-notification__icon"')
expect(source).toContain('class="phone-notification__open"')
expect(source).toContain(':aria-label="phone.t(\'Notifications.open\')"')
expect(source).toContain('@keydown.esc.stop="emit(\'close\')"')
})
it('supports an upward dismiss gesture without a visible close button', () => {
expect(source).toContain('@pointerdown="beginDismissGesture"')
expect(source).toContain('@pointermove="moveDismissGesture"')
expect(source).toContain('pointerOffset <= -28')
expect(source).toContain('velocityY <= -0.3')
expect(source).not.toContain('button="close"')
expect(source).not.toContain('<template #button>')
})
it('matches the compact popup placement and light/dark material', () => {
expect(styles).toMatch(
/\.phone-notification-provider\s*\{[^}]*--phone-notification-background:\s*rgb\(247 247 248 \/ 94%\);[^}]*position:\s*absolute;[^}]*pointer-events:\s*none;/s,
)
expect(styles).toMatch(
/\.phone-notification-provider\.sky-ui-provider--dark\s*\{[^}]*--phone-notification-background:\s*rgb\(36 36 39 \/ 94%\);/s,
)
expect(styles).toMatch(
/\.phone-notification\s*\{[^}]*top:\s*51px !important;[^}]*right:\s*10px !important;[^}]*left:\s*10px !important;[^}]*transform:\s*translate3d\(0, var\(--phone-notification-drag-y\), 0\);/s,
)
expect(styles).toMatch(
/\.phone-notification__icon\s*\{[^}]*width:\s*38px;[^}]*height:\s*38px;[^}]*border-radius:\s*10px;/s,
)
})
})
+129 -25
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { kNotification } from 'konsta/vue'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { getPhoneApp } from '@/config/apps'
import type { PhoneNotification } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import { SkyNotification, SkyProvider } from '@/ui'
const props = defineProps<{
dark?: boolean
notification: PhoneNotification | null
}>()
const emit = defineEmits<{
@@ -14,41 +15,144 @@ const emit = defineEmits<{
open: [notification: PhoneNotification]
}>()
const phone = usePhoneStore()
const notificationTime = ref('')
const darkMode = computed(() => props.dark ?? phone.isDarkMode)
const icon = computed(() =>
props.notification
? getPhoneApp(props.notification.appId)?.iconImage
: undefined,
)
let pointerElement: HTMLElement | null = null
let pointerId: number | null = null
let pointerStartX = 0
let pointerStartY = 0
let pointerStartedAt = 0
let pointerAxis: 'horizontal' | 'vertical' | null = null
let pointerOffset = 0
let suppressOpenUntil = 0
function openNotification(event: MouseEvent): void {
watch(
() => props.notification?.id,
(notificationId) => {
resetGesture()
if (!notificationId) {
notificationTime.value = ''
return
}
notificationTime.value = new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(new Date())
},
{ immediate: true },
)
function resetGesture(): void {
if (
!props.notification?.route ||
(event.target as HTMLElement).closest('button')
pointerElement &&
pointerId !== null &&
pointerElement.hasPointerCapture(pointerId)
) {
pointerElement.releasePointerCapture(pointerId)
}
pointerElement?.classList.remove('phone-notification--dragging')
pointerElement?.style.removeProperty('--phone-notification-drag-y')
pointerElement = null
pointerId = null
pointerAxis = null
pointerOffset = 0
}
function openNotification(): void {
if (!props.notification?.route || performance.now() < suppressOpenUntil)
return
emit('open', props.notification)
}
function beginDismissGesture(event: PointerEvent): void {
if (event.pointerType === 'mouse' && event.button !== 0) return
const element = event.currentTarget
if (!(element instanceof HTMLElement)) return
pointerElement = element
pointerId = event.pointerId
pointerStartX = event.clientX
pointerStartY = event.clientY
pointerStartedAt = performance.now()
pointerAxis = null
pointerOffset = 0
element.classList.add('phone-notification--dragging')
element.setPointerCapture(event.pointerId)
}
function moveDismissGesture(event: PointerEvent): void {
if (!pointerElement || pointerId !== event.pointerId) return
const deltaX = event.clientX - pointerStartX
const deltaY = event.clientY - pointerStartY
if (!pointerAxis && Math.max(Math.abs(deltaX), Math.abs(deltaY)) >= 6) {
pointerAxis =
Math.abs(deltaY) >= Math.abs(deltaX) ? 'vertical' : 'horizontal'
}
if (pointerAxis !== 'vertical') return
event.preventDefault()
pointerOffset = Math.max(-110, Math.min(8, deltaY))
pointerElement.style.setProperty(
'--phone-notification-drag-y',
`${pointerOffset}px`,
)
}
function finishDismissGesture(event: PointerEvent): void {
if (!pointerElement || pointerId !== event.pointerId) return
const element = pointerElement
const elapsed = Math.max(1, performance.now() - pointerStartedAt)
const velocityY = (event.clientY - pointerStartY) / elapsed
const shouldDismiss =
pointerAxis === 'vertical' && (pointerOffset <= -28 || velocityY <= -0.3)
if (element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId)
}
element.classList.remove('phone-notification--dragging')
pointerElement = null
pointerId = null
pointerAxis = null
if (shouldDismiss) {
suppressOpenUntil = performance.now() + 180
emit('close')
return
}
emit('open', props.notification)
pointerOffset = 0
element.style.setProperty('--phone-notification-drag-y', '0px')
}
</script>
<template>
<k-notification
:opened="!!notification"
:title="notification?.title"
:subtitle="notification?.subtitle"
:text="notification?.text"
:title-right-text="phone.t('Notifications.now')"
button="close"
class="phone-notification"
:class="{ 'is-actionable': !!notification?.route }"
@close="emit('close')"
@click="openNotification"
>
<template v-if="icon" #icon>
<img :src="icon" alt="" class="phone-notification__icon" />
</template>
<template #button>
<span class="sr-only">{{ phone.t('Common.close') }}</span>
</template>
</k-notification>
<SkyProvider class="phone-notification-provider" :dark="darkMode" safe-areas>
<SkyNotification
:opened="!!notification"
:title="notification?.title"
:subtitle="notification?.subtitle"
:text="notification?.text"
:title-right-text="notificationTime"
:role="notification?.critical ? 'alert' : 'status'"
class="phone-notification"
:class="{ 'is-actionable': !!notification?.route }"
@pointerdown="beginDismissGesture"
@pointermove="moveDismissGesture"
@pointerup="finishDismissGesture"
@pointercancel="finishDismissGesture"
>
<template v-if="icon" #icon>
<img :src="icon" alt="" class="phone-notification__icon" />
</template>
<button
v-if="notification?.route"
type="button"
class="phone-notification__open"
:aria-label="phone.t('Notifications.open')"
@click.stop="openNotification"
@keydown.esc.stop="emit('close')"
></button>
</SkyNotification>
</SkyProvider>
</template>