Merge branch 'dev' into weather-app

This commit is contained in:
Type
2026-08-16 23:39:22 +02:00
16 changed files with 1672 additions and 219 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>