mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Merge branch 'dev' into weather-app
This commit is contained in:
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user