mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
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:
@@ -1311,6 +1311,17 @@ onMounted(() => {
|
||||
if (developmentParameters.has('payphonePreview')) {
|
||||
openDevelopmentPayphonePreview()
|
||||
}
|
||||
if (developmentParameters.has('notificationPreview')) {
|
||||
window.setTimeout(() => {
|
||||
notifications.show({
|
||||
appId: 'messages',
|
||||
persistent: true,
|
||||
route: '/apps/messages',
|
||||
text: 'You still got that spare alternator?',
|
||||
title: 'Tommy V',
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -536,19 +536,62 @@ button {
|
||||
border: 0 !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.phone-notification-provider {
|
||||
--phone-notification-background: rgb(247 247 248 / 94%);
|
||||
--phone-notification-shadow:
|
||||
0 10px 30px rgb(0 0 0 / 18%), inset 0 0.5px 0 rgb(255 255 255 / 90%);
|
||||
position: absolute;
|
||||
z-index: 85;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.phone-notification-provider.sky-ui-provider--dark {
|
||||
--phone-notification-background: rgb(36 36 39 / 94%);
|
||||
--phone-notification-shadow:
|
||||
0 12px 34px rgb(0 0 0 / 36%), inset 0 0.5px 0 rgb(255 255 255 / 16%);
|
||||
}
|
||||
.phone-notification {
|
||||
--phone-notification-drag-y: 0px;
|
||||
z-index: 85 !important;
|
||||
top: 68px !important;
|
||||
right: 8px !important;
|
||||
left: 8px !important;
|
||||
top: 51px !important;
|
||||
right: 10px !important;
|
||||
left: 10px !important;
|
||||
width: auto !important;
|
||||
margin-left: 0 !important;
|
||||
pointer-events: auto;
|
||||
background: var(--phone-notification-background) !important;
|
||||
box-shadow: var(--phone-notification-shadow) !important;
|
||||
transform: translate3d(0, var(--phone-notification-drag-y), 0);
|
||||
transition: transform 280ms var(--sky-ease-out, ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
.phone-notification__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.phone-notification__open {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.phone-notification__open:active {
|
||||
background: var(--sky-pressed);
|
||||
}
|
||||
.phone-notification__open:focus-visible {
|
||||
outline: 2px solid var(--sky-app-accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.phone-notification.phone-notification--dragging {
|
||||
transition: none !important;
|
||||
}
|
||||
.phone-status-bar {
|
||||
position: absolute;
|
||||
z-index: 97;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -690,15 +690,17 @@
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: calc(var(--sky-safe-area-top, 0px) + 8px);
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
top: calc(var(--sky-safe-area-top, 0px) + 6px);
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 18px;
|
||||
color: var(--sky-text);
|
||||
box-shadow: var(--sky-shadow-glass);
|
||||
touch-action: none;
|
||||
@@ -732,8 +734,8 @@
|
||||
.sky-notification__time,
|
||||
.sky-notification__subtitle,
|
||||
.sky-notification__text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.sky-notification__title,
|
||||
@@ -742,17 +744,29 @@
|
||||
}
|
||||
|
||||
.sky-notification__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sky-notification__time {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-end: 0;
|
||||
flex: none;
|
||||
color: var(--sky-notification-meta);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.sky-notification__text {
|
||||
color: var(--sky-text);
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.sky-notification__close {
|
||||
@@ -790,14 +804,14 @@
|
||||
.sky-notification-slide-enter-active,
|
||||
.sky-notification-slide-leave-active {
|
||||
transition:
|
||||
opacity 500ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity 300ms ease,
|
||||
transform 380ms var(--sky-ease-out, ease-out);
|
||||
}
|
||||
|
||||
.sky-notification-slide-enter-from,
|
||||
.sky-notification-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
transform: translateY(-32px) scale(0.97);
|
||||
}
|
||||
|
||||
.sky-panel__panel,
|
||||
|
||||
@@ -21,13 +21,13 @@ async function renderNotification(
|
||||
}
|
||||
|
||||
describe('SkyNotification', () => {
|
||||
it('renders the complete Konsta iOS notification structure', async () => {
|
||||
it('renders the complete Sky popup notification structure', async () => {
|
||||
const html = await renderNotification({
|
||||
button: 'Close notification',
|
||||
opened: true,
|
||||
subtitle: 'Notification with close button',
|
||||
text: 'Click (x) button to close me',
|
||||
title: 'Konsta UI',
|
||||
title: 'Sky Phone',
|
||||
titleRightText: 'now',
|
||||
})
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('SkyNotification', () => {
|
||||
expect(html).not.toContain('Hidden')
|
||||
})
|
||||
|
||||
it('locks the Konsta iOS glass, typography, and close geometry', () => {
|
||||
it('locks the compact popup glass, typography, and close geometry', () => {
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
@@ -66,19 +66,23 @@ describe('SkyNotification', () => {
|
||||
expect(notificationRule).toBeDefined()
|
||||
expect(notificationRule).toMatch(/z-index:\s*50;/)
|
||||
expect(notificationRule).toMatch(
|
||||
/top:\s*calc\(var\(--sky-safe-area-top, 0px\) \+ 8px\);/,
|
||||
/top:\s*calc\(var\(--sky-safe-area-top, 0px\) \+ 6px\);/,
|
||||
)
|
||||
expect(notificationRule).toMatch(/right:\s*8px;[\s\S]*left:\s*8px;/)
|
||||
expect(notificationRule).toMatch(/gap:\s*16px;[\s\S]*padding:\s*16px;/)
|
||||
expect(notificationRule).toMatch(/right:\s*10px;[\s\S]*left:\s*10px;/)
|
||||
expect(notificationRule).toMatch(/min-height:\s*72px;/)
|
||||
expect(notificationRule).toMatch(/gap:\s*10px;[\s\S]*padding:\s*10px 12px;/)
|
||||
expect(notificationRule).toMatch(
|
||||
/border:\s*0;[\s\S]*border-radius:\s*24px;/,
|
||||
/border:\s*1px solid var\(--sky-hairline\);[\s\S]*border-radius:\s*18px;/,
|
||||
)
|
||||
expect(notificationRule).not.toMatch(/min-height|background:/)
|
||||
expect(notificationRule).not.toMatch(/background:/)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__title,[\s\S]*?font-size:\s*14px;[\s\S]*?line-height:\s*20px;/,
|
||||
/\.sky-notification__title,[\s\S]*?font-size:\s*12px;[\s\S]*?line-height:\s*16px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__time\s*\{[\s\S]*?margin-inline-start:\s*auto;[\s\S]*?margin-inline-end:\s*4px;[\s\S]*?color:\s*var\(--sky-notification-meta\);/,
|
||||
/\.sky-notification__title\s*\{[\s\S]*?font-size:\s*13px;[\s\S]*?text-overflow:\s*ellipsis;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__time\s*\{[\s\S]*?margin-inline-start:\s*auto;[\s\S]*?margin-inline-end:\s*0;[\s\S]*?color:\s*var\(--sky-notification-meta\);/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification__close\s*\{[\s\S]*?width:\s*var\(--sky-touch-target, 44px\);[\s\S]*?height:\s*var\(--sky-touch-target, 44px\);[\s\S]*?position:\s*absolute;[\s\S]*?right:\s*-8px;/,
|
||||
@@ -87,7 +91,7 @@ describe('SkyNotification', () => {
|
||||
/\.sky-notification__close-icon\s*\{\s*width:\s*20px;\s*height:\s*20px;/,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-notification-slide-enter-from,[\s\S]*?transform:\s*translateY\(-100%\);/,
|
||||
/\.sky-notification-slide-enter-from,[\s\S]*?transform:\s*translateY\(-32px\) scale\(0\.97\);/,
|
||||
)
|
||||
expect(tokens).toContain('--sky-notification-meta: rgba(0, 0, 0, 0.45)')
|
||||
expect(tokens).toContain(
|
||||
|
||||
Reference in New Issue
Block a user