mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - rework homescreen editing and widgets
This commit is contained in:
@@ -17,8 +17,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
@@ -27,13 +30,11 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -60,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)`,
|
||||
}
|
||||
@@ -173,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
|
||||
@@ -193,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)
|
||||
}
|
||||
@@ -235,6 +269,7 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragPageMetrics = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
@@ -265,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,
|
||||
}"
|
||||
@@ -285,7 +319,6 @@ onBeforeUnmount(() => {
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
>
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
@@ -340,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,8 +9,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
@@ -19,13 +22,11 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -45,6 +46,7 @@ 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
|
||||
@@ -53,14 +55,14 @@ 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)`,
|
||||
}
|
||||
@@ -84,18 +86,58 @@ 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
|
||||
@@ -110,10 +152,7 @@ function onPointerDown(event: PointerEvent): void {
|
||||
})
|
||||
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')
|
||||
@@ -124,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
|
||||
}
|
||||
@@ -139,6 +175,11 @@ function onPointerMove(event: PointerEvent): void {
|
||||
) {
|
||||
suppressClick.value = true
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
updateDragOffset(event)
|
||||
emit('dragmove', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +206,7 @@ function cancelPointerDrag(): void {
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
if (props.editMode || suppressClick.value) {
|
||||
if (suppressClick.value) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
@@ -191,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,
|
||||
@@ -210,7 +250,6 @@ onBeforeUnmount(() => {
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
>
|
||||
<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) {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user