ENH - rework homescreen editing and widgets

This commit is contained in:
DerEchteAlec
2026-08-17 05:16:28 +02:00
parent 6a8be4d679
commit 7d3a3b7b38
23 changed files with 2218 additions and 1115 deletions
+49 -16
View File
@@ -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>
+58 -19
View File
@@ -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,
)
})
})
+184 -129
View File
@@ -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>
+25 -8
View File
@@ -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,
)
})
})
+224 -149
View File
@@ -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,
)
})
})
+426 -163
View File
@@ -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>