mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
ENH - merge latest dev into weather app
Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
SkyBlock as kBlock,
|
||||
SkyBlockTitle as kBlockTitle,
|
||||
SkyButton as kButton,
|
||||
SkyField as kListInput,
|
||||
@@ -8,6 +7,8 @@ import {
|
||||
SkyList as kList,
|
||||
SkyListItem as kListItem,
|
||||
SkyNavbar as kNavbar,
|
||||
SkyScrollArea as kScrollArea,
|
||||
SkySheet as kSheet,
|
||||
} from '@/ui'
|
||||
import { ChevronRight, Check, X } from 'lucide-vue-next'
|
||||
import { reactive, ref } from 'vue'
|
||||
@@ -61,106 +62,123 @@ function toggleWeekday(weekday: number): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlarmSoundMenu
|
||||
v-if="soundMenuOpen"
|
||||
:back-label="phone.t('Apps.clock.tabs.alarm')"
|
||||
:selected-sound="draft.sound"
|
||||
@close="soundMenuOpen = false"
|
||||
@select="draft.sound = $event"
|
||||
/>
|
||||
|
||||
<k-navbar v-show="!soundMenuOpen" :title="phone.t('Apps.clock.tabs.alarm')">
|
||||
<template #left>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('Common.cancel')"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
class="clock-alarm-save"
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('Common.save')"
|
||||
@click="save"
|
||||
>
|
||||
<Check aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<k-block
|
||||
v-show="!soundMenuOpen"
|
||||
component="section"
|
||||
class="clock-alarm-editor"
|
||||
<k-sheet
|
||||
opened
|
||||
class="clock-alarm-sheet"
|
||||
:aria-label="phone.t('Apps.clock.tabs.alarm')"
|
||||
:show-grabber="false"
|
||||
swipe-to-close
|
||||
@backdropclick="emit('cancel')"
|
||||
@escape="emit('cancel')"
|
||||
@swipeclose="emit('cancel')"
|
||||
>
|
||||
<TimeWheelPicker
|
||||
v-model="draft.time"
|
||||
:hours-label="phone.t('Apps.clock.alarm.hours')"
|
||||
:label="phone.t('Apps.clock.alarm.time')"
|
||||
:minutes-label="phone.t('Apps.clock.minutes')"
|
||||
/>
|
||||
|
||||
<k-block-title>{{ phone.t('Apps.clock.alarm.repeat') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
v-for="weekday in WEEKDAY_IDS"
|
||||
:key="weekday"
|
||||
link
|
||||
:chevron="false"
|
||||
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
|
||||
@click="toggleWeekday(weekday)"
|
||||
>
|
||||
<template #after>
|
||||
<Check
|
||||
v-if="draft.weekdays.includes(weekday)"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-list strong inset>
|
||||
<k-list-input
|
||||
:label="phone.t('Apps.clock.alarm.note')"
|
||||
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
|
||||
:value="draft.note"
|
||||
maxlength="80"
|
||||
@input="setNote"
|
||||
<section class="clock-alarm-sheet-surface">
|
||||
<AlarmSoundMenu
|
||||
v-if="soundMenuOpen"
|
||||
:back-label="phone.t('Apps.clock.tabs.alarm')"
|
||||
:selected-sound="draft.sound"
|
||||
@close="soundMenuOpen = false"
|
||||
@select="draft.sound = $event"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
link
|
||||
:chevron="false"
|
||||
:title="phone.t('Apps.clock.alarm.sound')"
|
||||
@click="soundMenuOpen = true"
|
||||
>
|
||||
<template #after>
|
||||
<span class="clock-sound-selection">
|
||||
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<template v-else>
|
||||
<k-navbar
|
||||
class="clock-alarm-sheet-navbar"
|
||||
data-sky-sheet-drag-handle
|
||||
:title="phone.t('Apps.clock.tabs.alarm')"
|
||||
>
|
||||
<template #left>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('Common.cancel')"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
class="clock-alarm-save"
|
||||
:link-props="{ type: 'button' }"
|
||||
:aria-label="phone.t('Common.save')"
|
||||
@click="save"
|
||||
>
|
||||
<Check aria-hidden="true" />
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<k-button
|
||||
v-if="alarm"
|
||||
large
|
||||
rounded
|
||||
variant="danger"
|
||||
class="clock-alarm-delete"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ phone.t('Apps.clock.alarm.delete') }}
|
||||
</k-button>
|
||||
</k-block>
|
||||
<k-scroll-area padded class="clock-alarm-editor">
|
||||
<TimeWheelPicker
|
||||
v-model="draft.time"
|
||||
:hours-label="phone.t('Apps.clock.alarm.hours')"
|
||||
:label="phone.t('Apps.clock.alarm.time')"
|
||||
:minutes-label="phone.t('Apps.clock.minutes')"
|
||||
/>
|
||||
|
||||
<k-block-title>{{
|
||||
phone.t('Apps.clock.alarm.repeat')
|
||||
}}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
v-for="weekday in WEEKDAY_IDS"
|
||||
:key="weekday"
|
||||
link
|
||||
:chevron="false"
|
||||
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
|
||||
@click="toggleWeekday(weekday)"
|
||||
>
|
||||
<template #after>
|
||||
<Check
|
||||
v-if="draft.weekdays.includes(weekday)"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-list strong inset>
|
||||
<k-list-input
|
||||
:label="phone.t('Apps.clock.alarm.note')"
|
||||
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
|
||||
:value="draft.note"
|
||||
maxlength="80"
|
||||
@input="setNote"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
link
|
||||
:chevron="false"
|
||||
:title="phone.t('Apps.clock.alarm.sound')"
|
||||
@click="soundMenuOpen = true"
|
||||
>
|
||||
<template #after>
|
||||
<span class="clock-sound-selection">
|
||||
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-button
|
||||
v-if="alarm"
|
||||
large
|
||||
rounded
|
||||
variant="danger"
|
||||
class="clock-alarm-delete"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ phone.t('Apps.clock.alarm.delete') }}
|
||||
</k-button>
|
||||
</k-scroll-area>
|
||||
</template>
|
||||
</section>
|
||||
</k-sheet>
|
||||
</template>
|
||||
|
||||
@@ -17,22 +17,24 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -59,15 +61,16 @@ const calendarToday = ref(new Date())
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -84,6 +87,7 @@ let calendarTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
let stopPointerSession: (() => void) | null = null
|
||||
|
||||
watch(
|
||||
() => props.app.iconImage,
|
||||
@@ -149,6 +153,12 @@ function onPointerDown(event: PointerEvent): void {
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
stopPointerSession?.()
|
||||
stopPointerSession = bindPointerDragSession(window, pointerId, {
|
||||
cancel: cancelPointerDrag,
|
||||
move: onPointerMove,
|
||||
up: onPointerUp,
|
||||
})
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
@@ -165,9 +175,36 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
if (dragPageMetrics) {
|
||||
const pointer = springboardViewportToLocal(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
dragPageMetrics.viewportLeft,
|
||||
dragPageMetrics.viewportTop,
|
||||
dragPageMetrics.viewportWidth,
|
||||
dragPageMetrics.viewportHeight,
|
||||
dragPageMetrics.layoutWidth,
|
||||
dragPageMetrics.layoutHeight,
|
||||
)
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
dragPageMetrics.viewportLeft,
|
||||
dragPageMetrics.viewportTop,
|
||||
dragPageMetrics.viewportWidth,
|
||||
dragPageMetrics.viewportHeight,
|
||||
dragPageMetrics.layoutWidth,
|
||||
dragPageMetrics.layoutHeight,
|
||||
)
|
||||
dragOffset.value = {
|
||||
x: pointer.x - start.x,
|
||||
y: pointer.y - start.y,
|
||||
}
|
||||
} else {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
@@ -185,10 +222,15 @@ function onPointerMove(event: PointerEvent): void {
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
pointerTarget ??
|
||||
(event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null)
|
||||
dragPageMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragPageMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
@@ -216,6 +258,8 @@ function cancelPointerDrag(): void {
|
||||
}
|
||||
|
||||
function releasePointerCapture(): void {
|
||||
stopPointerSession?.()
|
||||
stopPointerSession = null
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
@@ -225,6 +269,7 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragPageMetrics = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
@@ -255,7 +300,6 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
@@ -274,12 +318,7 @@ onBeforeUnmount(() => {
|
||||
@click="launch"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointercancel="cancelPointerDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@lostpointercapture="cancelPointerDrag"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
@@ -334,9 +373,9 @@ onBeforeUnmount(() => {
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<k-badge class="app-icon-remove__badge" tone="neutral">
|
||||
<Minus :size="15" :stroke-width="3" aria-hidden="true" />
|
||||
</k-badge>
|
||||
<span class="app-icon-remove__badge" aria-hidden="true">
|
||||
<Minus :size="14" :stroke-width="4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,22 +9,24 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -44,21 +46,23 @@ const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragMetrics: SpringboardDragMetrics | null = null
|
||||
let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -71,6 +75,8 @@ function clearHold(): void {
|
||||
}
|
||||
|
||||
function releasePointerCapture(): void {
|
||||
stopPointerSession?.()
|
||||
stopPointerSession = null
|
||||
if (
|
||||
pointerTarget &&
|
||||
pointerId !== null &&
|
||||
@@ -80,30 +86,73 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragMetrics = null
|
||||
}
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
pointerTarget ??
|
||||
(event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null)
|
||||
dragMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function updateDragOffset(event: PointerEvent): void {
|
||||
if (dragMetrics) {
|
||||
const pointer = springboardViewportToLocal(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
dragMetrics.viewportLeft,
|
||||
dragMetrics.viewportTop,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
dragMetrics.viewportLeft,
|
||||
dragMetrics.viewportTop,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
dragOffset.value = {
|
||||
x: pointer.x - start.x,
|
||||
y: pointer.y - start.y,
|
||||
}
|
||||
return
|
||||
}
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return
|
||||
suppressClick.value = false
|
||||
pointerTarget = event.currentTarget as HTMLElement
|
||||
pointerId = event.pointerId
|
||||
pointerTarget.setPointerCapture(pointerId)
|
||||
stopPointerSession?.()
|
||||
stopPointerSession = bindPointerDragSession(window, pointerId, {
|
||||
cancel: cancelPointerDrag,
|
||||
move: onPointerMove,
|
||||
up: onPointerUp,
|
||||
})
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
return
|
||||
}
|
||||
if (props.editMode) return
|
||||
holdTimer = window.setTimeout(() => {
|
||||
suppressClick.value = true
|
||||
emit('edit')
|
||||
@@ -114,10 +163,7 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
updateDragOffset(event)
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
@@ -129,6 +175,11 @@ function onPointerMove(event: PointerEvent): void {
|
||||
) {
|
||||
suppressClick.value = true
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
updateDragOffset(event)
|
||||
emit('dragmove', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +206,7 @@ function cancelPointerDrag(): void {
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
if (props.editMode || suppressClick.value) {
|
||||
if (suppressClick.value) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
@@ -181,7 +232,6 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
@@ -199,12 +249,7 @@ onBeforeUnmount(() => {
|
||||
@click="openFolder"
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@lostpointercapture="cancelPointerDrag"
|
||||
@pointercancel="cancelPointerDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<span class="home-folder-preview" aria-hidden="true">
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const iconSource = readFileSync(
|
||||
new URL('./HomeFolderIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const overlaySource = readFileSync(
|
||||
new URL('./HomeFolderOverlay.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const dragSource = readFileSync(
|
||||
new URL('../utils/springboardDrag.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const springboardSource = readFileSync(
|
||||
new URL('../views/SpringboardView.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appIconSource = readFileSync(
|
||||
new URL('./AppIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
expect(iconSource).toContain('if (props.editMode) return')
|
||||
expect(iconSource).toMatch(
|
||||
/if \(props\.editMode\) \{\s+beginPointerDrag\(event\)/,
|
||||
)
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
expect(iconSource).toContain('springboardViewportToLocal')
|
||||
expect(iconSource).not.toContain('@pointerleave')
|
||||
expect(springboardSource).not.toContain(
|
||||
'(event.currentTarget as HTMLElement | null)?.closest',
|
||||
)
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
expect(overlaySource).toContain('<Check')
|
||||
expect(overlaySource).toContain('<X')
|
||||
expect(overlaySource).not.toContain('<SkyDialog')
|
||||
expect(overlaySource).not.toContain('<SkySheet')
|
||||
})
|
||||
|
||||
it('visually closes the folder while an app leaves and closes after extraction', () => {
|
||||
expect(overlaySource).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(overlaySource).toContain("emit('drag-outside-change', outside)")
|
||||
expect(overlaySource).toContain('home-folder-layer--dragging-out')
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
)
|
||||
expect(springboardSource).toContain(
|
||||
"'springboard--folder-open': folderOverlayVisible",
|
||||
)
|
||||
expect(springboardSource).toContain(
|
||||
'editMode && isEditablePage && !folderOverlayVisible',
|
||||
)
|
||||
expect(springboardSource).toMatch(
|
||||
/extractHomeFolderApp\([\s\S]*?closeFolder\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the folder title left-aligned and gives the rename field more height', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-heading\s*\{[^}]*justify-content:\s*flex-start;/s,
|
||||
)
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-title\s*\{[^}]*text-align:\s*left;/s,
|
||||
)
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-rename-field :deep\(\.sky-field__input\)\s*\{[^}]*height:\s*46px;[^}]*min-height:\s*46px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one native iOS-style removal badge inside and outside folders', () => {
|
||||
expect(appIconSource).toContain('<span class="app-icon-remove__badge"')
|
||||
expect(appIconSource).not.toContain(
|
||||
'<k-badge class="app-icon-remove__badge"',
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-remove__badge\s*\{[^}]*background:\s*rgb\(174 174 178 \/ 88%\);[^}]*color:\s*#111;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil } from 'lucide-vue-next'
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyField,
|
||||
SkyLink,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
import { SkyField, SkyLink } from '@/ui'
|
||||
import {
|
||||
HOME_FOLDER_NAME_MAX_LENGTH,
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
@@ -30,6 +25,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
move: [sourceIndex: number, targetIndex: number]
|
||||
@@ -41,6 +37,8 @@ const phone = usePhoneStore()
|
||||
const currentPage = ref(0)
|
||||
const pageTransitionDirection = ref<'backward' | 'forward'>('forward')
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const draggingOutside = ref(false)
|
||||
const panelElement = ref<HTMLElement | null>(null)
|
||||
const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
@@ -87,7 +85,9 @@ watch(
|
||||
emit('rename-opened')
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
.querySelector<HTMLInputElement>(
|
||||
'.home-folder-rename-field .sky-field__input',
|
||||
)
|
||||
?.select()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -98,36 +98,62 @@ function openRename(): void {
|
||||
renameOpened.value = true
|
||||
void nextTick(() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
.querySelector<HTMLInputElement>(
|
||||
'.home-folder-rename-field .sky-field__input',
|
||||
)
|
||||
?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveRename(): void {
|
||||
emit('rename', renameDraft.value.trim() || phone.t('Home.folders.defaultName'))
|
||||
emit(
|
||||
'rename',
|
||||
renameDraft.value.trim() || phone.t('Home.folders.defaultName'),
|
||||
)
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
setDraggingOutside(false)
|
||||
}
|
||||
|
||||
function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const sourceIndex = draggingIndex.value
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) return
|
||||
const panel = document.querySelector<HTMLElement>('.home-folder-panel')
|
||||
if (!panel) return
|
||||
const panelBounds = panel.getBoundingClientRect()
|
||||
const insidePanel =
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
return (
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
if (!insidePanel) {
|
||||
emit('extract', sourceIndex, event)
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
if (draggingOutside.value === outside) return
|
||||
draggingOutside.value = outside
|
||||
emit('drag-outside-change', outside)
|
||||
}
|
||||
|
||||
function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const sourceIndex = draggingIndex.value
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
@@ -143,6 +169,7 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
draggingIndex.value = null
|
||||
setDraggingOutside(false)
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -171,7 +198,10 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-folder-layer">
|
||||
<div
|
||||
class="home-folder-layer"
|
||||
:class="{ 'home-folder-layer--dragging-out': draggingOutside }"
|
||||
>
|
||||
<button
|
||||
class="home-folder-backdrop"
|
||||
type="button"
|
||||
@@ -185,7 +215,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
:aria-label="folderName"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="home-folder-heading">
|
||||
<div v-if="!renameOpened" class="home-folder-heading">
|
||||
<button
|
||||
class="home-folder-title"
|
||||
type="button"
|
||||
@@ -205,7 +235,46 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
<Pencil :size="18" :stroke-width="2.2" />
|
||||
</SkyLink>
|
||||
</div>
|
||||
<form
|
||||
v-else
|
||||
class="home-folder-heading home-folder-heading--editing"
|
||||
@keydown.esc.prevent="renameOpened = false"
|
||||
@submit.prevent="saveRename"
|
||||
>
|
||||
<SkyLink
|
||||
class="home-folder-rename-action"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Common.cancel')"
|
||||
type="button"
|
||||
@click="renameOpened = false"
|
||||
>
|
||||
<X :size="19" :stroke-width="2.3" />
|
||||
</SkyLink>
|
||||
<SkyField
|
||||
v-model="renameDraft"
|
||||
class="home-folder-rename-field"
|
||||
:aria-label="phone.t('Home.folders.name')"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
clear-button
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
component="div"
|
||||
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
|
||||
:placeholder="phone.t('Home.folders.defaultName')"
|
||||
/>
|
||||
<SkyLink
|
||||
class="home-folder-rename-action"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Common.save')"
|
||||
type="submit"
|
||||
>
|
||||
<Check :size="20" :stroke-width="2.5" />
|
||||
</SkyLink>
|
||||
</form>
|
||||
<div
|
||||
ref="panelElement"
|
||||
class="home-folder-panel"
|
||||
:class="{ 'home-folder-panel--dragging': draggingIndex !== null }"
|
||||
@pointerdown="startPageSwipe"
|
||||
@@ -223,6 +292,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
@@ -245,51 +315,6 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SkySheet
|
||||
class="home-folder-rename"
|
||||
:opened="renameOpened"
|
||||
:aria-label="phone.t('Home.folders.rename')"
|
||||
@backdropclick="renameOpened = false"
|
||||
@escape="renameOpened = false"
|
||||
>
|
||||
<form class="home-folder-rename__content" @submit.prevent="saveRename">
|
||||
<span class="home-folder-rename__handle" aria-hidden="true"></span>
|
||||
<header>
|
||||
<div>
|
||||
<small>{{ phone.t('Home.folders.name') }}</small>
|
||||
<h2>{{ phone.t('Home.folders.rename') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="home-folder-rename__fields">
|
||||
<SkyField
|
||||
v-model="renameDraft"
|
||||
:aria-label="phone.t('Home.folders.name')"
|
||||
autocomplete="off"
|
||||
clear-button
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
|
||||
outline
|
||||
:placeholder="phone.t('Home.folders.defaultName')"
|
||||
/>
|
||||
</ul>
|
||||
<div class="home-folder-rename__actions">
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="renameOpened = false"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyButton>
|
||||
<SkyButton block large rounded type="submit">
|
||||
{{ phone.t('Common.save') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</form>
|
||||
</SkySheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -322,30 +347,32 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
|
||||
.home-folder-dialog {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
top: 118px;
|
||||
right: 30px;
|
||||
left: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.home-folder-heading {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.home-folder-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 49px);
|
||||
min-height: 44px;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 2px 12px;
|
||||
padding: 2px 8px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
@@ -356,7 +383,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 8px rgb(0 0 0 / 38%);
|
||||
white-space: nowrap;
|
||||
@@ -388,11 +415,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
.home-folder-panel {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 342px;
|
||||
padding: 25px 22px 16px;
|
||||
min-height: 324px;
|
||||
padding: 22px 16px 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 28px;
|
||||
border-radius: 26px;
|
||||
background: rgb(115 135 176 / 72%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 18%),
|
||||
@@ -406,8 +433,8 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
|
||||
.home-folder-page-viewport {
|
||||
position: relative;
|
||||
min-height: 285px;
|
||||
margin-top: -8px;
|
||||
min-height: 270px;
|
||||
margin-top: -6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -418,11 +445,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
.home-folder-page-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 8px;
|
||||
top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, 79px);
|
||||
gap: 13px 20px;
|
||||
gap: 10px 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -465,6 +492,36 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.home-folder-backdrop,
|
||||
.home-folder-heading,
|
||||
.home-folder-panel,
|
||||
.home-folder-page-grid :deep(.app-icon-item),
|
||||
.home-folder-pages {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-backdrop {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-heading,
|
||||
.home-folder-layer--dragging-out .home-folder-pages,
|
||||
.home-folder-layer--dragging-out
|
||||
.home-folder-page-grid
|
||||
:deep(.app-icon-item:not(.app-icon-item--dragging)) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-panel {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.home-folder-pages {
|
||||
min-height: 20px;
|
||||
margin-top: 5px;
|
||||
@@ -491,59 +548,57 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.home-folder-rename {
|
||||
--sky-overlay-layer: 90;
|
||||
.home-folder-heading--editing {
|
||||
min-height: 52px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-folder-rename :deep(.sky-sheet__panel) {
|
||||
.home-folder-rename-field {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 26px 26px 0 0;
|
||||
background: rgb(28 28 30 / 98%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content {
|
||||
padding: 9px 16px calc(20px + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.home-folder-rename__handle {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content header {
|
||||
margin: 0 2px 14px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content header small {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--sky-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content h2 {
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 21px;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
.home-folder-rename__fields {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
border: 1px solid rgb(255 255 255 / 26%);
|
||||
border-radius: 14px;
|
||||
background: rgb(28 28 30 / 78%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.home-folder-rename__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
.home-folder-rename-field :deep(.sky-field__inner) {
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__input) {
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__input::placeholder) {
|
||||
color: rgb(255 255 255 / 48%);
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__clear) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.home-folder-rename-action {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 13%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.home-folder-rename-action:active {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const component = readFileSync(
|
||||
new URL('./MessageAttachmentBubble.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const styles = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('MessageAttachmentBubble contract', () => {
|
||||
it('renders an optional caption below media', () => {
|
||||
expect(component).toContain('message.body?.trim()')
|
||||
expect(component).toContain('messages-attachment-caption')
|
||||
})
|
||||
|
||||
it('gives image-only messages an accessible name', () => {
|
||||
expect(component).toContain('role="img"')
|
||||
expect(component).toContain('Apps.photos.photoAlt')
|
||||
})
|
||||
|
||||
it('keeps remote GIFs proportional instead of cover-cropping them', () => {
|
||||
expect(component).toContain('messages-attachment--remote')
|
||||
expect(styles).toMatch(
|
||||
/\.messages-attachment--gif\.messages-attachment--remote img\s*\{[^}]*width:\s*auto;[^}]*height:\s*auto;[^}]*object-fit:\s*contain;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
import type { SmsMessageType } from '@/types/messages'
|
||||
|
||||
type MessageAttachment = {
|
||||
body?: string
|
||||
media_asset_id: string | null
|
||||
media_duration_ms: number | null
|
||||
message_type: SmsMessageType
|
||||
@@ -82,6 +83,8 @@ function durationLabel(milliseconds: number | null): string {
|
||||
<div
|
||||
v-if="message.message_type === 'image'"
|
||||
class="messages-attachment messages-attachment--image"
|
||||
role="img"
|
||||
:aria-label="phone.t('Apps.photos.photoAlt')"
|
||||
:style="{ background }"
|
||||
>
|
||||
<img
|
||||
@@ -121,18 +124,18 @@ function durationLabel(milliseconds: number | null): string {
|
||||
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
|
||||
v-else-if="playing"
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
/><Play
|
||||
v-else
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
fill="currentColor" /><Play v-else :size="22" fill="currentColor"
|
||||
/></span>
|
||||
<small v-if="playbackFailed">{{
|
||||
phone.t('Apps.photos.errors.unsupported')
|
||||
}}</small>
|
||||
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
</button>
|
||||
<div v-else class="messages-attachment messages-attachment--gif">
|
||||
<div
|
||||
v-else
|
||||
class="messages-attachment messages-attachment--gif"
|
||||
:class="{ 'messages-attachment--remote': mediaUrl }"
|
||||
>
|
||||
<img
|
||||
v-if="mediaUrl"
|
||||
:src="mediaUrl"
|
||||
@@ -145,4 +148,16 @@ function durationLabel(milliseconds: number | null): string {
|
||||
<strong>{{ gif.label }}</strong>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="message.body?.trim()" class="messages-attachment-caption">
|
||||
{{ message.body }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.messages-attachment-caption {
|
||||
max-width: 205px;
|
||||
margin: 7px 1px 1px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,9 @@ const frameImage = computed(
|
||||
)
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
...getHairlinePixelStyle(props.zoom, props.devicePixelRatio),
|
||||
zoom: props.zoom,
|
||||
'--phone-rendered-height': `${844 * props.zoom}px`,
|
||||
'--phone-rendered-width': `${390 * props.zoom}px`,
|
||||
'--phone-zoom': props.zoom,
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -41,52 +43,54 @@ const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--notification"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<section
|
||||
class="phone-device phone-device--notification"
|
||||
:class="{
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<div
|
||||
class="phone-screen"
|
||||
<div class="phone-resolution-canvas">
|
||||
<section
|
||||
class="phone-device phone-device--notification"
|
||||
:class="{
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="isDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
<div
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
dark: isDarkMode,
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
>
|
||||
<PhoneLockScreen
|
||||
:notifications="[]"
|
||||
:preferences="preferences"
|
||||
preview
|
||||
/>
|
||||
<PhoneNotifications
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="isDarkMode"
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
@open="emit('open', $event)"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="frameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:class="{
|
||||
dark: isDarkMode,
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
>
|
||||
<PhoneLockScreen
|
||||
:notifications="[]"
|
||||
:preferences="preferences"
|
||||
preview
|
||||
/>
|
||||
<PhoneNotifications
|
||||
:dark="isDarkMode"
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
@open="emit('open', $event)"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="frameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,8 +31,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportDeltaToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
@@ -78,6 +81,7 @@ let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragMetrics: SpringboardDragMetrics | null = null
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
@@ -207,10 +211,18 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
const deltaX = event.clientX - pointerStart.x
|
||||
const deltaY = event.clientY - pointerStart.y
|
||||
dragOffset.value = dragMetrics
|
||||
? springboardViewportDeltaToLocal(
|
||||
deltaX,
|
||||
deltaY,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
: { x: deltaX, y: deltaY }
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
@@ -227,10 +239,14 @@ function onPointerMove(event: PointerEvent): void {
|
||||
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.currentTarget as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
dragMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
@@ -265,6 +281,7 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragMetrics = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./VoiceMessageBubble.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('VoiceMessageBubble contract', () => {
|
||||
it('uses the central accessible range to seek protected audio', () => {
|
||||
expect(source).toContain("import { SkyRange } from '@/ui'")
|
||||
expect(source).toContain('<SkyRange')
|
||||
expect(source).toContain("phone.t('Apps.messages.seekAudio')")
|
||||
expect(source).toContain('@input="seekPlayback"')
|
||||
expect(source).toContain('audio.value.currentTime = nextTime')
|
||||
expect(source).toContain('await ensureSource()')
|
||||
expect(source).toContain(
|
||||
'let sourceLoadPromise: Promise<boolean> | undefined',
|
||||
)
|
||||
expect(source).toContain('return sourceLoadPromise')
|
||||
expect(source).toContain(':aria-value-text="seekValueText"')
|
||||
})
|
||||
|
||||
it('fits every stored sample into the available waveform width', () => {
|
||||
expect(source).toMatch(
|
||||
/\.voice-message__waveform i\s*\{[^}]*min-width:\s*1px;[^}]*flex:\s*1 1 1px;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { SmsMessage } from '@/types/messages'
|
||||
import { SkyRange } from '@/ui'
|
||||
|
||||
const props = defineProps<{ message: SmsMessage }>()
|
||||
const phone = usePhoneStore()
|
||||
@@ -14,13 +15,20 @@ const currentTime = ref(0)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const duration = computed(() => (props.message.media_duration_ms ?? 0) / 1000)
|
||||
const metadataDuration = ref(0)
|
||||
let sourceLoadPromise: Promise<boolean> | undefined
|
||||
const duration = computed(
|
||||
() => metadataDuration.value || (props.message.media_duration_ms ?? 0) / 1000,
|
||||
)
|
||||
const progress = computed(() =>
|
||||
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
|
||||
)
|
||||
const displayTime = computed(() =>
|
||||
formatDuration(playing.value || currentTime.value > 0 ? currentTime.value : duration.value),
|
||||
formatDuration(
|
||||
playing.value || currentTime.value > 0 ? currentTime.value : duration.value,
|
||||
),
|
||||
)
|
||||
const seekValueText = computed(() => formatDuration(currentTime.value))
|
||||
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -28,6 +36,26 @@ function formatDuration(seconds: number): string {
|
||||
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function ensureSource(): Promise<boolean> {
|
||||
if (source.value) return Boolean(audio.value)
|
||||
if (!sourceLoadPromise) {
|
||||
loading.value = true
|
||||
sourceLoadPromise = (async () => {
|
||||
const loaded = await messages.loadMedia(props.message.id)
|
||||
if (!loaded) {
|
||||
failed.value = true
|
||||
return false
|
||||
}
|
||||
await nextTick()
|
||||
return Boolean(audio.value && source.value)
|
||||
})().finally(() => {
|
||||
loading.value = false
|
||||
sourceLoadPromise = undefined
|
||||
})
|
||||
}
|
||||
return sourceLoadPromise
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (loading.value) return
|
||||
failed.value = false
|
||||
@@ -35,24 +63,27 @@ async function togglePlayback(): Promise<void> {
|
||||
audio.value?.pause()
|
||||
return
|
||||
}
|
||||
if (!source.value) {
|
||||
loading.value = true
|
||||
const loaded = await messages.loadMedia(props.message.id)
|
||||
loading.value = false
|
||||
if (!loaded) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
}
|
||||
if (!(await ensureSource())) return
|
||||
try {
|
||||
await audio.value?.play()
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
console.error(`[Messages] Could not play audio message ${props.message.id}:`, error)
|
||||
console.error(
|
||||
`[Messages] Could not play audio message ${props.message.id}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function seekPlayback(event: Event): Promise<void> {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
failed.value = false
|
||||
if (!Number.isFinite(value) || !(await ensureSource()) || !audio.value) return
|
||||
const nextTime = Math.min(duration.value, Math.max(0, value))
|
||||
audio.value.currentTime = nextTime
|
||||
currentTime.value = nextTime
|
||||
}
|
||||
|
||||
function updateProgress(): void {
|
||||
currentTime.value = audio.value?.currentTime ?? 0
|
||||
}
|
||||
@@ -62,6 +93,11 @@ function finishPlayback(): void {
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
function updateDuration(): void {
|
||||
const value = audio.value?.duration ?? 0
|
||||
if (Number.isFinite(value) && value > 0) metadataDuration.value = value
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => audio.value?.pause())
|
||||
</script>
|
||||
|
||||
@@ -71,7 +107,11 @@ onBeforeUnmount(() => audio.value?.pause())
|
||||
type="button"
|
||||
class="voice-message__play"
|
||||
:disabled="loading"
|
||||
:aria-label="phone.t(playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio')"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio',
|
||||
)
|
||||
"
|
||||
@click="togglePlayback"
|
||||
>
|
||||
<span v-if="loading" class="voice-message__loader" />
|
||||
@@ -79,12 +119,29 @@ onBeforeUnmount(() => audio.value?.pause())
|
||||
<Play v-else :size="16" fill="currentColor" />
|
||||
</button>
|
||||
<div class="voice-message__content">
|
||||
<div class="voice-message__waveform" aria-hidden="true">
|
||||
<i
|
||||
v-for="(sample, index) in message.media_waveform ?? []"
|
||||
:key="index"
|
||||
:class="{ active: index / Math.max(1, (message.media_waveform?.length ?? 1) - 1) <= progress }"
|
||||
:style="{ height: `${Math.max(4, sample * 22)}px` }"
|
||||
<div class="voice-message__timeline">
|
||||
<div class="voice-message__waveform" aria-hidden="true">
|
||||
<i
|
||||
v-for="(sample, index) in message.media_waveform ?? []"
|
||||
:key="index"
|
||||
:class="{
|
||||
active:
|
||||
index /
|
||||
Math.max(1, (message.media_waveform?.length ?? 1) - 1) <=
|
||||
progress,
|
||||
}"
|
||||
:style="{ height: `${Math.max(4, sample * 22)}px` }"
|
||||
/>
|
||||
</div>
|
||||
<SkyRange
|
||||
class="voice-message__range"
|
||||
:model-value="currentTime"
|
||||
:min="0"
|
||||
:max="Math.max(0.1, duration)"
|
||||
:step="0.1"
|
||||
:aria-label="phone.t('Apps.messages.seekAudio')"
|
||||
:aria-value-text="seekValueText"
|
||||
@input="seekPlayback"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ displayTime }}</span>
|
||||
@@ -93,6 +150,7 @@ onBeforeUnmount(() => audio.value?.pause())
|
||||
ref="audio"
|
||||
:src="source"
|
||||
preload="metadata"
|
||||
@loadedmetadata="updateDuration"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@timeupdate="updateProgress"
|
||||
@@ -101,3 +159,43 @@ onBeforeUnmount(() => audio.value?.pause())
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-message__timeline {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.voice-message__waveform {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.voice-message__waveform i {
|
||||
width: auto;
|
||||
min-width: 1px;
|
||||
flex: 1 1 1px;
|
||||
}
|
||||
|
||||
.voice-message__range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-message__range :deep(.sky-range__input) {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-message__timeline:focus-within {
|
||||
border-radius: var(--sky-radius-pill);
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const configSource = readFileSync(
|
||||
new URL('./WidgetConfigSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const pickerSource = readFileSync(
|
||||
new URL('./WidgetPickerSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const widgetSource = readFileSync(
|
||||
new URL('./SpringboardWidget.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WidgetConfigSheet Sky UI contract', () => {
|
||||
it('uses first-party Sky UI controls for the configuration surface', () => {
|
||||
expect(configSource).not.toContain("from 'konsta/vue'")
|
||||
expect(configSource).not.toMatch(/<\/?k-[a-z]/)
|
||||
expect(configSource).toContain('<SkyProvider')
|
||||
expect(configSource).toContain('<SkySheet')
|
||||
expect(configSource).toContain('<SkyScrollArea')
|
||||
expect(configSource).toContain('<SkySegmented')
|
||||
expect(configSource).toContain('<SkySettingsGroup')
|
||||
expect(configSource).toContain('<SkySettingsRow')
|
||||
})
|
||||
|
||||
it('keeps the sheet inside the phone-owned springboard geometry', () => {
|
||||
expect(configSource).not.toContain('<Teleport')
|
||||
expect(configSource).toMatch(
|
||||
/\.widget-config-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(configSource).toMatch(
|
||||
/\.widget-config-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('has one scroll owner and preserves all configurable values', () => {
|
||||
expect(configSource.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
expect(configSource).toContain('balanceSource: balanceSource.value')
|
||||
expect(configSource).toContain('contactIds: contactIds.value')
|
||||
expect(configSource).toContain('showDate: showDate.value')
|
||||
expect(configSource).toContain('supportedSizes')
|
||||
})
|
||||
|
||||
it('lets the widget component own preview geometry for every size', () => {
|
||||
expect(configSource).not.toContain(':deep(.home-widget-shell--preview)')
|
||||
expect(pickerSource).not.toContain(':deep(.home-widget-shell--preview)')
|
||||
expect(widgetSource).toMatch(
|
||||
/\.home-widget-shell--preview\.home-widget-shell--small\s*\{[^}]*max-width:\s*150px;[^}]*aspect-ratio:\s*1;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import {
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
@@ -22,6 +10,16 @@ import type {
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyProvider,
|
||||
SkyScrollArea,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkySettingsGroup,
|
||||
SkySettingsRow,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: WidgetInstance | null
|
||||
@@ -39,9 +37,15 @@ const size = ref<WidgetSize>('small')
|
||||
const showDate = ref(true)
|
||||
const balanceSource = ref<'bank' | 'cash'>('bank')
|
||||
const contactIds = ref<string[]>([])
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
const supportedSizes = computed(
|
||||
() =>
|
||||
(props.instance &&
|
||||
WIDGET_REGISTRY_BY_KIND.get(props.instance.kind)?.supportedSizes) ||
|
||||
[],
|
||||
)
|
||||
const activeSizeIndex = computed(() =>
|
||||
Math.max(0, supportedSizes.value.indexOf(size.value)),
|
||||
)
|
||||
|
||||
function toggleContact(id: string): void {
|
||||
const index = contactIds.value.indexOf(id)
|
||||
@@ -71,191 +75,260 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-config-sheet">
|
||||
<k-sheet
|
||||
<SkyProvider
|
||||
class="widget-config-provider"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<SkySheet
|
||||
class="widget-config-sheet"
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
:aria-label="phone.t('Home.widgetSystem.configure')"
|
||||
grabber-clickable
|
||||
:grabber-label="phone.t('Common.cancel')"
|
||||
swipe-to-close
|
||||
@backdropclick="emit('close')"
|
||||
@escape="emit('close')"
|
||||
@grabberclick="emit('close')"
|
||||
@swipeclose="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
v-if="instance"
|
||||
class="widget-config-page"
|
||||
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
|
||||
<template #left>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
<section v-if="instance" class="widget-config-surface">
|
||||
<header class="widget-config-navbar">
|
||||
<SkyButton
|
||||
class="widget-config-nav-button"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="save">
|
||||
</SkyButton>
|
||||
<h2>{{ phone.t('Home.widgetSystem.configure') }}</h2>
|
||||
<SkyButton
|
||||
class="widget-config-nav-button"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="save"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
</SkyButton>
|
||||
</header>
|
||||
|
||||
<div class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
<SkyScrollArea class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="widget-config-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
?.supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
<section class="widget-config-size">
|
||||
<h3>{{ phone.t('Home.widgetSystem.size') }}</h3>
|
||||
<SkySegmented
|
||||
:active-index="activeSizeIndex"
|
||||
:aria-label="phone.t('Home.widgetSystem.size')"
|
||||
:item-count="supportedSizes.length"
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
<SkySegmentedButton
|
||||
v-for="supportedSize in supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</section>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'clock'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
|
||||
<template #after>
|
||||
<k-toggle :checked="showDate" @change="showDate = !showDate" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'clock'"
|
||||
class="widget-config-group"
|
||||
:aria-label="phone.t('Home.widgetSystem.clock.name')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
v-model="showDate"
|
||||
kind="toggle"
|
||||
:title="phone.t('Home.widgetSystem.clock.showDate')"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'wallet'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
|
||||
<template #after>
|
||||
<k-segmented class="widget-config-balance">
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'wallet'"
|
||||
class="widget-config-group"
|
||||
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
kind="custom"
|
||||
:title="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
>
|
||||
<template #trailing>
|
||||
<SkySegmented
|
||||
:active-index="balanceSource === 'bank' ? 0 : 1"
|
||||
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
class="widget-config-balance"
|
||||
:item-count="2"
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<SkySegmentedButton
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</template>
|
||||
</SkySettingsRow>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<section v-if="instance.kind === 'contacts'">
|
||||
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
|
||||
<k-list inset strong class="widget-config-list">
|
||||
<k-list-item
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'contacts'"
|
||||
class="widget-config-group"
|
||||
:title="phone.t('Home.widgetSystem.contacts.choose')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
v-for="contact in contactsService.contacts.value"
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
kind="choice"
|
||||
:description="contact.phone_number"
|
||||
:selected="contactIds.includes(contact.id)"
|
||||
:title="contact.name"
|
||||
:subtitle="contact.phone_number"
|
||||
@click="toggleContact(contact.id)"
|
||||
@activate="toggleContact(contact.id)"
|
||||
>
|
||||
<template #media>
|
||||
<template #leading>
|
||||
<span class="widget-config-avatar">{{
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-if="contactIds.includes(contact.id)" #after>
|
||||
<Check :size="20" class="widget-config-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</div>
|
||||
</SkySettingsRow>
|
||||
</SkySettingsGroup>
|
||||
</SkyScrollArea>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</SkyProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-config-sheet :deep(.k-sheet) {
|
||||
.widget-config-provider {
|
||||
position: absolute;
|
||||
z-index: 115;
|
||||
height: calc(100% - 22px);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-config-sheet {
|
||||
--sky-overlay-layer: 115;
|
||||
}
|
||||
|
||||
.widget-config-sheet :deep(.sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 58%);
|
||||
}
|
||||
|
||||
.widget-config-sheet :deep(.sky-sheet__panel) {
|
||||
height: calc(100% - var(--sky-space-3));
|
||||
max-height: calc(100% - var(--sky-space-3));
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
background: var(--sky-bg);
|
||||
}
|
||||
|
||||
.widget-config-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
.widget-config-surface {
|
||||
height: calc(100% - 32px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-config-page--dark {
|
||||
.widget-config-navbar {
|
||||
min-height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sky-space-2);
|
||||
padding: 0 var(--sky-space-3);
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
}
|
||||
|
||||
.widget-config-navbar h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: var(--sky-font-title);
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-config-navbar > :first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.widget-config-navbar > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.widget-config-navbar :deep(.widget-config-nav-button.sky-button) {
|
||||
min-height: var(--sky-touch-target);
|
||||
padding: 0 var(--sky-space-4);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
background: var(--sky-surface-variant);
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-config-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 12px 42px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-config-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
padding: var(--sky-space-3) var(--sky-page-gutter)
|
||||
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.widget-config-preview {
|
||||
display: flex;
|
||||
min-height: 215px;
|
||||
padding: 20px 12px;
|
||||
min-height: 250px;
|
||||
padding: var(--sky-space-4) 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-config-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-config-size {
|
||||
display: grid;
|
||||
margin: 0 4px 16px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-config-size > span,
|
||||
.widget-config-scroll h3 {
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
.widget-config-size h3 {
|
||||
margin: 0 0 var(--sky-space-2) var(--sky-space-3);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-config-scroll h3 {
|
||||
margin: 18px 14px 7px;
|
||||
.widget-config-size :deep(.sky-segmented) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widget-config-list :deep([class*='title']) {
|
||||
color: inherit;
|
||||
.widget-config-group {
|
||||
margin: 0 0 var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-config-balance {
|
||||
width: 138px;
|
||||
width: 146px;
|
||||
}
|
||||
|
||||
.widget-config-avatar {
|
||||
@@ -269,7 +342,9 @@ watch(
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-config-check {
|
||||
color: #0a84ff;
|
||||
@media (max-height: 700px) {
|
||||
.widget-config-preview {
|
||||
min-height: 205px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./WidgetPickerSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WidgetPickerSheet Sky UI contract', () => {
|
||||
it('uses only first-party Sky UI primitives for the picker surface', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).not.toMatch(/<\/?k-[a-z]/)
|
||||
expect(source).toContain('<SkyProvider')
|
||||
expect(source).toContain('<SkySheet')
|
||||
expect(source).toContain('<SkySearchbar')
|
||||
expect(source).toContain('<SkyList')
|
||||
expect(source).toContain('<SkyListItem')
|
||||
expect(source).toContain('<SkyEmptyState')
|
||||
expect(source).toContain('<SkyButton')
|
||||
})
|
||||
|
||||
it('keeps the modal inside the phone-owned springboard geometry', () => {
|
||||
expect(source).not.toContain('<Teleport')
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the iOS gallery, preview, size, and add sequence', () => {
|
||||
expect(source).toContain("ref<'gallery' | 'preview'>('gallery')")
|
||||
expect(source).toContain("pickerView.value = 'preview'")
|
||||
expect(source).toContain("pickerView.value = 'gallery'")
|
||||
expect(source).toContain('selectedDefinition.supportedSizes')
|
||||
expect(source).toContain('startPreviewSwipe')
|
||||
expect(source).toContain('widget-picker-size-dots')
|
||||
expect(source).toContain("phone.t('Home.widgetSystem.addWidget')")
|
||||
})
|
||||
|
||||
it('has one explicit vertical scroll owner', () => {
|
||||
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-scroll\s*\{[^}]*overflow-y:\s*auto;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Clock3,
|
||||
CloudSun,
|
||||
Music,
|
||||
@@ -9,18 +9,6 @@ import {
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
@@ -32,6 +20,16 @@ import type {
|
||||
WidgetKind,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyEmptyState,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyProvider,
|
||||
SkyScrollArea,
|
||||
SkySearchbar,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -40,8 +38,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const query = ref('')
|
||||
const pickerView = ref<'gallery' | 'preview'>('gallery')
|
||||
const selectedKind = ref<WidgetKind>('clock')
|
||||
const selectedSize = ref<WidgetSize>('small')
|
||||
let previewPointerStartX: number | null = null
|
||||
|
||||
const icons: Record<WidgetKind, Component> = {
|
||||
clock: Clock3,
|
||||
@@ -90,214 +90,447 @@ const previewInstance = computed<WidgetInstance>(() => ({
|
||||
},
|
||||
size: selectedSize.value,
|
||||
}))
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
const selectedSizeIndex = computed(() =>
|
||||
selectedDefinition.value.supportedSizes.indexOf(selectedSize.value),
|
||||
)
|
||||
|
||||
function selectWidget(definition: WidgetDefinition): void {
|
||||
selectedKind.value = definition.kind
|
||||
if (!definition.supportedSizes.includes(selectedSize.value))
|
||||
selectedSize.value = definition.defaultSize
|
||||
selectedSize.value = definition.defaultSize
|
||||
pickerView.value = 'preview'
|
||||
}
|
||||
|
||||
function addWidget(): void {
|
||||
emit('add', selectedKind.value, selectedSize.value)
|
||||
}
|
||||
|
||||
function closePicker(): void {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function showGallery(): void {
|
||||
pickerView.value = 'gallery'
|
||||
}
|
||||
|
||||
function changeSize(offset: number): void {
|
||||
const sizes = selectedDefinition.value.supportedSizes
|
||||
const currentIndex = Math.max(0, sizes.indexOf(selectedSize.value))
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(sizes.length - 1, currentIndex + offset),
|
||||
)
|
||||
selectedSize.value = sizes[nextIndex] ?? selectedSize.value
|
||||
}
|
||||
|
||||
function startPreviewSwipe(event: PointerEvent): void {
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
(event.pointerType === 'mouse' && event.button !== 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
previewPointerStartX = event.clientX
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function finishPreviewSwipe(event: PointerEvent): void {
|
||||
if (previewPointerStartX === null) return
|
||||
|
||||
const deltaX = event.clientX - previewPointerStartX
|
||||
previewPointerStartX = null
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (target.hasPointerCapture(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
if (Math.abs(deltaX) < 36) return
|
||||
changeSize(deltaX < 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
function cancelPreviewSwipe(): void {
|
||||
previewPointerStartX = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(opened) => {
|
||||
if (!opened) query.value = ''
|
||||
if (opened) return
|
||||
query.value = ''
|
||||
pickerView.value = 'gallery'
|
||||
previewPointerStartX = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-picker-sheet">
|
||||
<k-sheet
|
||||
<SkyProvider
|
||||
class="widget-picker-provider"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<SkySheet
|
||||
class="widget-picker-sheet"
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
:aria-label="phone.t('Home.widgetSystem.galleryTitle')"
|
||||
grabber-clickable
|
||||
:grabber-label="phone.t('Common.close')"
|
||||
swipe-to-close
|
||||
@backdropclick="closePicker"
|
||||
@escape="closePicker"
|
||||
@grabberclick="closePicker"
|
||||
@swipeclose="closePicker"
|
||||
>
|
||||
<k-page
|
||||
class="widget-picker-page"
|
||||
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
<section class="widget-picker-surface">
|
||||
<header class="widget-picker-navbar">
|
||||
<SkyButton
|
||||
v-if="pickerView === 'preview'"
|
||||
class="widget-picker-nav-button widget-picker-nav-button--icon"
|
||||
icon-only
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="showGallery"
|
||||
>
|
||||
<ChevronLeft :size="21" :stroke-width="2.4" />
|
||||
</SkyButton>
|
||||
<span v-else aria-hidden="true"></span>
|
||||
|
||||
<h2>
|
||||
{{
|
||||
pickerView === 'gallery'
|
||||
? phone.t('Home.widgetSystem.galleryTitle')
|
||||
: phone.t(selectedDefinition.labelKey)
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<SkyButton
|
||||
class="widget-picker-nav-button widget-picker-done"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="closePicker"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
</SkyButton>
|
||||
</header>
|
||||
|
||||
<div class="widget-picker-scroll">
|
||||
<k-searchbar
|
||||
:value="query"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
@input="query = inputValue($event)"
|
||||
@clear="query = ''"
|
||||
/>
|
||||
<SkyScrollArea class="widget-picker-scroll">
|
||||
<template v-if="pickerView === 'gallery'">
|
||||
<SkySearchbar
|
||||
v-model="query"
|
||||
class="widget-picker-search"
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
:label="phone.t('Home.widgetSystem.search')"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
/>
|
||||
|
||||
<section class="widget-picker-preview">
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="widget-picker-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="size in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
:active="selectedSize === size"
|
||||
@click="selectedSize = size"
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<SkyList flush strong class="widget-picker-list">
|
||||
<SkyListItem
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
chevron
|
||||
link
|
||||
link-component="button"
|
||||
:text="phone.t(definition.descriptionKey)"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
@click="selectWidget(definition)"
|
||||
>
|
||||
<template #media>
|
||||
<span
|
||||
class="widget-picker-icon"
|
||||
:class="`widget-picker-icon--${definition.kind}`"
|
||||
>
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
</SkyList>
|
||||
</section>
|
||||
|
||||
<k-button large rounded class="widget-picker-add" @click="addWidget">
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</k-button>
|
||||
<SkyEmptyState
|
||||
v-if="filteredWidgets.length === 0"
|
||||
compact
|
||||
class="widget-picker-empty"
|
||||
:title="phone.t('Home.widgetSystem.noResults')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
:subtitle="phone.t(definition.descriptionKey)"
|
||||
@click="selectWidget(definition)"
|
||||
<section v-else class="widget-picker-preview">
|
||||
<div
|
||||
class="widget-picker-preview-stage"
|
||||
role="group"
|
||||
tabindex="0"
|
||||
:aria-label="`${phone.t('Home.widgetSystem.size')}: ${phone.t(`Home.widgetSystem.sizes.${selectedSize}`)}`"
|
||||
@keydown.left.prevent="changeSize(-1)"
|
||||
@keydown.right.prevent="changeSize(1)"
|
||||
@lostpointercapture="cancelPreviewSwipe"
|
||||
@pointercancel="cancelPreviewSwipe"
|
||||
@pointerdown="startPreviewSwipe"
|
||||
@pointerup="finishPreviewSwipe"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-picker-icon">
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="selectedKind === definition.kind" #after>
|
||||
<Check :size="20" class="widget-picker-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
|
||||
{{ phone.t('Home.widgetSystem.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</div>
|
||||
<div class="widget-picker-size-copy" aria-live="polite">
|
||||
<strong>{{
|
||||
phone.t(`Home.widgetSystem.sizes.${selectedSize}`)
|
||||
}}</strong>
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="widget-picker-size-dots"
|
||||
:aria-label="phone.t('Home.widgetSystem.size')"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
v-for="(size, index) in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
type="button"
|
||||
:class="{
|
||||
'widget-picker-size-dot--active': index === selectedSizeIndex,
|
||||
}"
|
||||
:aria-label="phone.t(`Home.widgetSystem.sizes.${size}`)"
|
||||
:aria-pressed="index === selectedSizeIndex"
|
||||
@click="selectedSize = size"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<h3>{{ phone.t(selectedDefinition.labelKey) }}</h3>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
rounded
|
||||
class="widget-picker-add"
|
||||
@click="addWidget"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</SkyButton>
|
||||
</section>
|
||||
</SkyScrollArea>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</SkyProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-picker-sheet :deep(.k-sheet) {
|
||||
.widget-picker-provider {
|
||||
position: absolute;
|
||||
z-index: 110;
|
||||
height: calc(100% - 22px);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-picker-sheet {
|
||||
--sky-overlay-layer: 110;
|
||||
}
|
||||
|
||||
.widget-picker-sheet :deep(.sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 58%);
|
||||
}
|
||||
|
||||
.widget-picker-sheet :deep(.sky-sheet__panel) {
|
||||
height: calc(100% - var(--sky-space-3));
|
||||
max-height: calc(100% - var(--sky-space-3));
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
background: var(--sky-bg);
|
||||
}
|
||||
|
||||
.widget-picker-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
.widget-picker-surface {
|
||||
height: calc(100% - 32px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-picker-page--dark {
|
||||
.widget-picker-navbar {
|
||||
min-height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sky-space-2);
|
||||
padding: 0 var(--sky-space-3);
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
}
|
||||
|
||||
.widget-picker-navbar h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: var(--sky-font-title);
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-picker-navbar > :first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.widget-picker-navbar > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.widget-picker-navbar :deep(.widget-picker-nav-button.sky-button) {
|
||||
min-height: var(--sky-touch-target);
|
||||
padding: 0 var(--sky-space-4);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
background: var(--sky-surface-variant);
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-navbar :deep(.widget-picker-nav-button--icon.sky-button) {
|
||||
width: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.widget-picker-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 13px 42px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding: var(--sky-space-3) var(--sky-page-gutter)
|
||||
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.widget-picker-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
.widget-picker-search {
|
||||
margin-bottom: var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-picker-preview {
|
||||
display: flex;
|
||||
min-height: 235px;
|
||||
padding: 22px 14px 14px;
|
||||
min-height: 100%;
|
||||
padding: var(--sky-space-4) 0 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-picker-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-picker-preview h2 {
|
||||
margin: 14px 0 3px;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 285px;
|
||||
margin: 0;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark .widget-picker-preview p {
|
||||
color: #98989d;
|
||||
}
|
||||
|
||||
.widget-picker-size {
|
||||
display: grid;
|
||||
margin: 0 4px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
.widget-picker-preview-stage {
|
||||
width: 100%;
|
||||
min-height: 310px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
outline: 0;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-picker-size > span {
|
||||
font-size: 13px;
|
||||
.widget-picker-preview-stage:focus-visible {
|
||||
border-radius: var(--sky-radius-card);
|
||||
box-shadow: inset 0 0 0 2px var(--sky-app-accent);
|
||||
}
|
||||
|
||||
.widget-picker-size-copy {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--sky-space-2);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
}
|
||||
|
||||
.widget-picker-size-copy strong {
|
||||
color: var(--sky-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-size-dots {
|
||||
min-height: var(--sky-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sky-space-2);
|
||||
}
|
||||
|
||||
.widget-picker-size-dots button {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--sky-subtle);
|
||||
transition: background-color var(--sky-transition-fast) ease;
|
||||
}
|
||||
|
||||
.widget-picker-size-dots button::before {
|
||||
position: absolute;
|
||||
inset: -18px -8px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.widget-picker-size-dots .widget-picker-size-dot--active {
|
||||
background: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-picker-preview h3 {
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.35px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 290px;
|
||||
margin: var(--sky-space-1) 0 var(--sky-space-5);
|
||||
color: var(--sky-muted);
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.widget-picker-add {
|
||||
margin-bottom: 22px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.widget-picker-category h3 {
|
||||
margin: 18px 12px 7px;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: var(--sky-space-5) var(--sky-space-3) var(--sky-space-2);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-picker-category :deep([class*='title']) {
|
||||
color: inherit;
|
||||
.widget-picker-category:first-of-type h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.widget-picker-list {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border-radius: var(--sky-radius-card);
|
||||
}
|
||||
|
||||
.widget-picker-list :deep(.sky-list-item__content) {
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.widget-picker-list :deep(.sky-list-item__text) {
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.widget-picker-icon {
|
||||
@@ -305,18 +538,48 @@ watch(
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: #2c2c2e;
|
||||
}
|
||||
|
||||
.widget-picker-check {
|
||||
color: #0a84ff;
|
||||
.widget-picker-icon--date {
|
||||
background: #ff453a;
|
||||
}
|
||||
|
||||
.widget-picker-icon--weather {
|
||||
background: #0a84ff;
|
||||
}
|
||||
|
||||
.widget-picker-icon--music {
|
||||
background: #ff375f;
|
||||
}
|
||||
|
||||
.widget-picker-icon--wallet {
|
||||
background: #5856d6;
|
||||
}
|
||||
|
||||
.widget-picker-icon--transactions {
|
||||
background: #30b35a;
|
||||
}
|
||||
|
||||
.widget-picker-icon--contacts {
|
||||
background: #ff9f0a;
|
||||
}
|
||||
|
||||
.widget-picker-empty {
|
||||
padding: 40px 20px;
|
||||
color: #8e8e93;
|
||||
text-align: center;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.widget-picker-preview-stage {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.widget-picker-size-dots button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,17 +32,27 @@ const props = withDefaults(
|
||||
maxUsernameLength?: number
|
||||
minUsernameLength?: number
|
||||
mode: 'login' | 'register'
|
||||
password?: string
|
||||
passwordLabel?: string
|
||||
passwordPlaceholder?: string
|
||||
pending: boolean
|
||||
registerLabel: string
|
||||
requirePassword?: boolean
|
||||
title: string
|
||||
username: string
|
||||
usernameLabel: string
|
||||
usernamePlaceholder?: string
|
||||
variant?: 'default' | 'centered'
|
||||
}>(),
|
||||
{
|
||||
maxUsernameLength: 40,
|
||||
minUsernameLength: 2,
|
||||
password: '',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: '',
|
||||
requirePassword: false,
|
||||
usernamePlaceholder: '',
|
||||
variant: 'default',
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
@@ -50,21 +60,30 @@ const emit = defineEmits<{
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:password': [value: string]
|
||||
'update:username': [value: string]
|
||||
}>()
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const length = props.username.trim().length
|
||||
const validUsername =
|
||||
length >= props.minUsernameLength && length <= props.maxUsernameLength
|
||||
return Boolean(
|
||||
props.email &&
|
||||
length >= props.minUsernameLength &&
|
||||
length <= props.maxUsernameLength,
|
||||
(props.mode === 'login' && props.requirePassword
|
||||
? true
|
||||
: validUsername) &&
|
||||
(!props.requirePassword ||
|
||||
(props.password.length >= 8 && props.password.length <= 72)),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-profile-auth">
|
||||
<section
|
||||
class="app-profile-auth"
|
||||
:class="{ 'app-profile-auth--centered': variant === 'centered' }"
|
||||
>
|
||||
<header class="app-profile-auth__hero">
|
||||
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
|
||||
<div>
|
||||
@@ -75,7 +94,39 @@ const canSubmit = computed(() => {
|
||||
</header>
|
||||
|
||||
<k-glass class="app-profile-auth__card">
|
||||
<k-segmented raised class="app-profile-auth__mode">
|
||||
<div
|
||||
v-if="variant === 'centered'"
|
||||
class="app-profile-auth__mode app-profile-auth__mode--centered"
|
||||
role="tablist"
|
||||
:aria-label="`${loginLabel} / ${registerLabel}`"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="app-profile-auth__mode-choice"
|
||||
:class="{
|
||||
'app-profile-auth__mode-choice--active': mode === 'login',
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="app-profile-auth__mode-choice"
|
||||
:class="{
|
||||
'app-profile-auth__mode-choice--active': mode === 'register',
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<k-segmented v-else raised class="app-profile-auth__mode">
|
||||
<k-segmented-button
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
|
||||
:class="{
|
||||
@@ -123,7 +174,64 @@ const canSubmit = computed(() => {
|
||||
<LockKeyhole :size="15" />
|
||||
</div>
|
||||
|
||||
<k-list inset strong class="app-profile-auth__fields">
|
||||
<label
|
||||
v-if="
|
||||
variant === 'centered' && (mode === 'register' || !requirePassword)
|
||||
"
|
||||
class="app-profile-auth__username-field"
|
||||
for="app-profile-auth-username"
|
||||
>
|
||||
<span><UserRound :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ usernameLabel }}</small>
|
||||
<input
|
||||
id="app-profile-auth-username"
|
||||
:value="username"
|
||||
:maxlength="maxUsernameLength"
|
||||
:placeholder="usernamePlaceholder"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@input="
|
||||
emit('update:username', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="variant === 'centered' && requirePassword"
|
||||
class="app-profile-auth__password-field"
|
||||
for="app-profile-auth-password"
|
||||
>
|
||||
<span><LockKeyhole :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ passwordLabel }}</small>
|
||||
<input
|
||||
id="app-profile-auth-password"
|
||||
:value="password"
|
||||
maxlength="72"
|
||||
:placeholder="passwordPlaceholder"
|
||||
:autocomplete="
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
type="password"
|
||||
@input="
|
||||
emit('update:password', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<k-list
|
||||
v-else-if="variant !== 'centered'"
|
||||
inset
|
||||
strong
|
||||
class="app-profile-auth__fields"
|
||||
>
|
||||
<k-list-input
|
||||
input-id="app-profile-auth-username"
|
||||
:label="usernameLabel"
|
||||
@@ -380,6 +488,102 @@ const canSubmit = computed(() => {
|
||||
.app-profile-auth__identity > svg {
|
||||
color: var(--muted, #9ba4aa);
|
||||
}
|
||||
.app-profile-auth__username-field {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 11px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__password-field {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 11px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__password-field > span {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--auth-accent, #ffd63e);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
|
||||
}
|
||||
.app-profile-auth__password-field div {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-profile-auth__password-field small {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 9px;
|
||||
}
|
||||
.app-profile-auth__password-field input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
.app-profile-auth__password-field input::placeholder {
|
||||
color: var(--muted, #9ba4aa);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.app-profile-auth__username-field > span {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--auth-accent, #ffd63e);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
|
||||
}
|
||||
.app-profile-auth__username-field div {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-profile-auth__username-field small {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 9px;
|
||||
}
|
||||
.app-profile-auth__username-field input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
.app-profile-auth__username-field input::placeholder {
|
||||
color: var(--muted, #9ba4aa);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.app-profile-auth__fields {
|
||||
margin-top: 0;
|
||||
margin-right: 0;
|
||||
@@ -427,4 +631,110 @@ const canSubmit = computed(() => {
|
||||
.app-profile-auth__submit:disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
.app-profile-auth--centered {
|
||||
max-width: 340px;
|
||||
padding: 0;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 18px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__mark {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__hero h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__hero p {
|
||||
max-width: 285px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: 14px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.app-profile-auth__mode-choice {
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #b8c5ce;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.app-profile-auth__mode-choice--active {
|
||||
color: #fff;
|
||||
background: var(--auth-accent, #ffd63e);
|
||||
box-shadow: 0 6px 16px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 28%, transparent);
|
||||
font-weight: 750;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__identity,
|
||||
.app-profile-auth--centered .app-profile-auth__username-field,
|
||||
.app-profile-auth--centered .app-profile-auth__password-field {
|
||||
grid-template-columns: 38px minmax(0, 1fr) 18px;
|
||||
min-height: 58px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 17px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__username-field {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__password-field {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__identity > span,
|
||||
.app-profile-auth--centered .app-profile-auth__username-field > span,
|
||||
.app-profile-auth--centered .app-profile-auth__password-field > span {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__identity small,
|
||||
.app-profile-auth--centered .app-profile-auth__username-field small,
|
||||
.app-profile-auth--centered .app-profile-auth__password-field small {
|
||||
font-size: 10px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__identity strong,
|
||||
.app-profile-auth--centered .app-profile-auth__username-field input,
|
||||
.app-profile-auth--centered .app-profile-auth__password-field input {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__submit {
|
||||
min-height: 48px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
Share2,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
SkyButton as kButton,
|
||||
SkyGlass as kGlass,
|
||||
SkyIcon as kIcon,
|
||||
} from '@/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { SkyButton, SkyGlass, SkyIcon } from '@/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { FeatherPost } from '@/types/feather'
|
||||
|
||||
const props = defineProps<{ post: FeatherPost }>()
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
follow: [post: FeatherPost]
|
||||
media: [post: FeatherPost, index: number]
|
||||
menu: [post: FeatherPost]
|
||||
@@ -32,6 +28,9 @@ defineEmits<{
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const expanded = ref(false)
|
||||
const reactionPulse = ref<'like' | 'bookmark' | null>(null)
|
||||
let reactionPulseFrame: number | null = null
|
||||
let reactionPulseTimer: number | null = null
|
||||
const shouldTruncate = computed(() => props.post.body.length > 260)
|
||||
const visibleBody = computed(() =>
|
||||
shouldTruncate.value && !expanded.value
|
||||
@@ -57,10 +56,40 @@ function relativeTime(timestamp: number): string {
|
||||
month: 'short',
|
||||
}).format(timestamp)
|
||||
}
|
||||
|
||||
function playReactionPulse(kind: 'like' | 'bookmark'): void {
|
||||
if (reactionPulseFrame !== null)
|
||||
window.cancelAnimationFrame(reactionPulseFrame)
|
||||
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
|
||||
reactionPulse.value = null
|
||||
void nextTick(() => {
|
||||
reactionPulseFrame = window.requestAnimationFrame(() => {
|
||||
reactionPulse.value = kind
|
||||
reactionPulseFrame = null
|
||||
reactionPulseTimer = window.setTimeout(() => {
|
||||
reactionPulse.value = null
|
||||
reactionPulseTimer = null
|
||||
}, 520)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function react(kind: 'like' | 'bookmark'): void {
|
||||
const isActivating =
|
||||
kind === 'like' ? !props.post.is_liked : !props.post.is_bookmarked
|
||||
if (isActivating) playReactionPulse(kind)
|
||||
emit('react', props.post, kind)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reactionPulseFrame !== null)
|
||||
window.cancelAnimationFrame(reactionPulseFrame)
|
||||
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kGlass :highlight="false" class="feather-post-glass">
|
||||
<SkyGlass :highlight="false" class="feather-post-glass">
|
||||
<article class="feather-post" @click="$emit('open', post)">
|
||||
<button
|
||||
class="feather-avatar"
|
||||
@@ -91,7 +120,7 @@ function relativeTime(timestamp: number): string {
|
||||
@{{ post.handle }} · {{ relativeTime(post.created_at) }}
|
||||
</span>
|
||||
</button>
|
||||
<kButton
|
||||
<SkyButton
|
||||
v-if="!post.is_owner && !post.is_following"
|
||||
outline
|
||||
inline
|
||||
@@ -101,16 +130,18 @@ function relativeTime(timestamp: number): string {
|
||||
@click.stop="$emit('follow', post)"
|
||||
>
|
||||
{{ phone.t('Apps.feather.follow') }}
|
||||
</kButton>
|
||||
<kButton
|
||||
clear
|
||||
</SkyButton>
|
||||
<SkyButton
|
||||
icon-only
|
||||
rounded
|
||||
small
|
||||
tonal
|
||||
class="feather-more"
|
||||
:aria-label="phone.t('Apps.feather.moreActions')"
|
||||
@click.stop="$emit('menu', post)"
|
||||
>
|
||||
<kIcon><MoreHorizontal :size="18" /></kIcon>
|
||||
</kButton>
|
||||
<SkyIcon><MoreHorizontal :size="18" /></SkyIcon>
|
||||
</SkyButton>
|
||||
</header>
|
||||
|
||||
<p v-if="post.body" class="feather-post__text">
|
||||
@@ -155,16 +186,24 @@ function relativeTime(timestamp: number): string {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-liked': post.is_liked }"
|
||||
@click.stop="$emit('react', post, 'like')"
|
||||
class="feather-action feather-action--like"
|
||||
:class="{
|
||||
'is-liked': post.is_liked,
|
||||
'is-pulsing': reactionPulse === 'like',
|
||||
}"
|
||||
@click.stop="react('like')"
|
||||
>
|
||||
<Heart :size="17" :fill="post.is_liked ? 'currentColor' : 'none'" />
|
||||
<span v-if="post.like_count">{{ post.like_count }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ 'is-bookmarked': post.is_bookmarked }"
|
||||
@click.stop="$emit('react', post, 'bookmark')"
|
||||
class="feather-action feather-action--bookmark"
|
||||
:class="{
|
||||
'is-bookmarked': post.is_bookmarked,
|
||||
'is-pulsing': reactionPulse === 'bookmark',
|
||||
}"
|
||||
@click.stop="react('bookmark')"
|
||||
>
|
||||
<Bookmark
|
||||
:size="17"
|
||||
@@ -177,7 +216,7 @@ function relativeTime(timestamp: number): string {
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</kGlass>
|
||||
</SkyGlass>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -267,8 +306,12 @@ function relativeTime(timestamp: number): string {
|
||||
color: #438cf5;
|
||||
}
|
||||
.feather-follow {
|
||||
--sky-app-accent: transparent;
|
||||
--sky-button-text: var(--feather-blue, #1d9bf0);
|
||||
--sky-app-accent: var(--feather-blue, #1d9bf0);
|
||||
--sky-app-accent-soft: color-mix(
|
||||
in srgb,
|
||||
var(--feather-blue, #1d9bf0) 14%,
|
||||
transparent
|
||||
);
|
||||
width: auto;
|
||||
min-width: 58px;
|
||||
max-width: 68px;
|
||||
@@ -281,17 +324,26 @@ function relativeTime(timestamp: number): string {
|
||||
transparent
|
||||
);
|
||||
padding-inline: 9px;
|
||||
color: var(--feather-blue, #1d9bf0);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.feather-more {
|
||||
--sky-app-accent: #71767b;
|
||||
--sky-app-accent-soft: color-mix(in srgb, currentColor 10%, transparent);
|
||||
flex: 0 0 auto;
|
||||
margin-left: 0;
|
||||
width: 27px;
|
||||
min-width: 27px;
|
||||
height: 27px;
|
||||
min-height: 27px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
padding: 0;
|
||||
color: #71767b;
|
||||
background: color-mix(in srgb, currentColor 7%, transparent);
|
||||
}
|
||||
.feather-post__text {
|
||||
margin: 2px 0 8px;
|
||||
@@ -352,6 +404,7 @@ function relativeTime(timestamp: number): string {
|
||||
color: #71767b;
|
||||
}
|
||||
.feather-actions button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -371,6 +424,57 @@ function relativeTime(timestamp: number): string {
|
||||
.feather-actions .is-bookmarked {
|
||||
color: #438cf5;
|
||||
}
|
||||
.feather-actions .feather-action.is-pulsing svg {
|
||||
animation: feather-reaction-pop 520ms cubic-bezier(0.22, 1.45, 0.36, 1) both;
|
||||
}
|
||||
.feather-actions .feather-action.is-pulsing::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
animation: feather-reaction-ring 520ms ease-out both;
|
||||
}
|
||||
.feather-actions .feather-action--like.is-pulsing {
|
||||
color: #f04f87;
|
||||
}
|
||||
.feather-actions .feather-action--bookmark.is-pulsing {
|
||||
color: #438cf5;
|
||||
}
|
||||
@keyframes feather-reaction-pop {
|
||||
0% {
|
||||
transform: scale(0.78) rotate(-8deg);
|
||||
}
|
||||
48% {
|
||||
transform: scale(1.42) rotate(7deg);
|
||||
}
|
||||
72% {
|
||||
transform: scale(0.94) rotate(-2deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
@keyframes feather-reaction-ring {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(0.35);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.65);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.feather-actions .feather-action.is-pulsing svg,
|
||||
.feather-actions .feather-action.is-pulsing::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@supports not (color: color-mix(in srgb, white, black)) {
|
||||
.feather-post:active {
|
||||
background: rgb(127 127 127 / 4%);
|
||||
|
||||
Reference in New Issue
Block a user