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
+200 -187
View File
@@ -257,8 +257,7 @@ type CrewLinkNotificationData = {
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
const DEVELOPMENT_PHONE_SCALE = 1.25
const PHONE_BASE_SCALE = 0.69 * 1.2
const PHONE_PORTRAIT_WIDTH = 390
const PHONE_PORTRAIT_HEIGHT = 844
const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
@@ -295,6 +294,7 @@ const easyShare = useEasyShareStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
const isHomeRoute = computed(() => route.name === 'home')
const isAppRoute = computed(() => route.name === 'app')
const activeAppId = computed(() =>
typeof route.params.appId === 'string' ? route.params.appId : '',
@@ -323,6 +323,7 @@ const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
const isLocked = ref(false)
const springboardEditing = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
const passcodeError = ref('')
@@ -363,11 +364,7 @@ const hardwareVolumeHudStyle = computed<CSSProperties>(
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
const browserDevicePixelRatio = ref(window.devicePixelRatio || 1)
const phoneBaseZoom = computed(
() =>
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
const phoneBaseZoom = computed(() => viewportScale.value * PHONE_BASE_SCALE)
const phoneZoom = computed(() => {
const preferred =
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
@@ -395,6 +392,14 @@ const phoneZoom = computed(() => {
const phoneResolutionStyle = computed<CSSProperties>(() => ({
...getHairlinePixelStyle(phoneZoom.value, browserDevicePixelRatio.value),
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-rendered-height': `${
(phone.cameraLandscape ? PHONE_PORTRAIT_WIDTH : PHONE_PORTRAIT_HEIGHT) *
phoneZoom.value
}px`,
'--phone-rendered-width': `${
(phone.cameraLandscape ? PHONE_PORTRAIT_HEIGHT : PHONE_PORTRAIT_WIDTH) *
phoneZoom.value
}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
'--phone-zoom': phoneZoom.value,
}))
@@ -1535,7 +1540,6 @@ onBeforeUnmount(() => {
"
class="phone-stage"
:class="{
'phone-stage--dev': isDevelopment,
'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--peek': notifications.isPeeking,
}"
@@ -1559,200 +1563,209 @@ onBeforeUnmount(() => {
v-if="phone.isOpen || notifications.current"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
>
<button
type="button"
class="phone-hardware-button phone-hardware-button--action"
:aria-label="
phone.t(
hardwareAlertVolume > 0
? 'HardwareButtons.mute'
: 'HardwareButtons.unmute',
)
"
:aria-pressed="hardwareAlertVolume === 0"
:disabled="setupRequired"
@click="toggleHardwareAlertMute"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-up"
:aria-label="phone.t('HardwareButtons.volumeUp')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-down"
:aria-label="phone.t('HardwareButtons.volumeDown')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(-10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--power"
:aria-label="
phone.t(
isLocked ? 'HardwareButtons.unlock' : 'HardwareButtons.lock',
)
"
:disabled="setupRequired"
@click="toggleHardwareLock"
></button>
<div
class="phone-screen"
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
<section
class="phone-device"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
>
<Transition name="phone-volume-hud">
<div
v-if="hardwareVolumeHudVisible"
class="phone-volume-hud"
:style="hardwareVolumeHudStyle"
role="status"
aria-live="polite"
:aria-label="
phone.t('HardwareButtons.volumeLevel', {
value: String(hardwareAlertVolume),
})
"
>
<svg
class="phone-volume-hud__icon"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path d="M4 9v6h4l5 4V5L8 9H4Z" />
<path
v-if="hardwareAlertVolume > 0"
d="M16 8.2c1.1 1 1.7 2.2 1.7 3.8s-.6 2.8-1.7 3.8"
/>
<path v-else d="m16.2 9.2 4.6 4.6m0-4.6-4.6 4.6" />
</svg>
<span
class="phone-volume-hud__level"
aria-hidden="true"
></span>
</div>
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
<button
type="button"
class="phone-hardware-button phone-hardware-button--action"
:aria-label="
phone.t(
hardwareAlertVolume > 0
? 'HardwareButtons.mute'
: 'HardwareButtons.unmute',
)
"
:aria-pressed="hardwareAlertVolume === 0"
:disabled="setupRequired"
@click="toggleHardwareAlertMute"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-up"
:aria-label="phone.t('HardwareButtons.volumeUp')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--volume-down"
:aria-label="phone.t('HardwareButtons.volumeDown')"
:disabled="setupRequired"
@click="changeHardwareAlertVolume(-10)"
></button>
<button
type="button"
class="phone-hardware-button phone-hardware-button--power"
:aria-label="
phone.t(
isLocked
? 'HardwareButtons.unlock'
: 'HardwareButtons.lock',
)
"
:disabled="setupRequired"
@click="toggleHardwareLock"
></button>
<div
class="phone-screen"
:class="{
dark: phone.isDarkMode,
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-app--light': !phone.isDarkMode,
'phone-app--messages': route.params.appId === 'messages',
'phone-app--status-light':
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--status-dark':
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
>
<PhoneStatusBar
v-if="!isLocked"
:active-call-return="showActiveCallReturn"
:control-center-opened="controlCenterOpened"
:interactive="!setupRequired"
:lockable="!setupRequired"
@active-call="returnToActiveCall"
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<SpringboardView v-if="!isDevelopmentRoute && !setupRequired" />
<SkyProvider
class="phone-app-theme"
<Transition name="phone-volume-hud">
<div
v-if="hardwareVolumeHudVisible"
class="phone-volume-hud"
:style="hardwareVolumeHudStyle"
role="status"
aria-live="polite"
:aria-label="
phone.t('HardwareButtons.volumeLevel', {
value: String(hardwareAlertVolume),
})
"
>
<svg
class="phone-volume-hud__icon"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path d="M4 9v6h4l5 4V5L8 9H4Z" />
<path
v-if="hardwareAlertVolume > 0"
d="M16 8.2c1.1 1 1.7 2.2 1.7 3.8s-.6 2.8-1.7 3.8"
/>
<path v-else d="m16.2 9.2 4.6 4.6m0-4.6-4.6 4.6" />
</svg>
<span
class="phone-volume-hud__level"
aria-hidden="true"
></span>
</div>
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
'phone-app--messages': route.params.appId === 'messages',
'phone-app--status-light':
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--status-dark':
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
>
<RouterView v-slot="{ Component }">
<Transition :name="appTransitionName">
<component
:is="Component"
v-if="
!setupRequired && (isAppRoute || isDevelopmentRoute)
"
:key="
isDevelopmentRoute ? String(route.name) : route.path
"
/>
</Transition>
</RouterView>
</SkyProvider>
<PhoneHomeIndicator v-if="!isLocked && !setupRequired" />
<PhoneControlCenter
v-if="!setupRequired"
:opened="controlCenterOpened"
@close="controlCenterOpened = false"
/>
<Transition name="lock-screen" @after-leave="completeUnlock">
<PhoneLockScreen
v-if="isLocked && !setupRequired"
:notifications="notifications.lockScreenNotifications"
@camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="notifications.dismissFromLockScreen"
@open-notification="openLockScreenNotification"
@unlock="unlockPhone"
<PhoneStatusBar
v-if="!isLocked && !(isHomeRoute && springboardEditing)"
:active-call-return="showActiveCallReturn"
:control-center-opened="controlCenterOpened"
:interactive="!setupRequired"
:lockable="!setupRequired"
@active-call="returnToActiveCall"
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible && !setupRequired"
:busy="passcodeBusy"
:disabled="passcodeRetrySeconds > 0"
:error="passcodeError"
:length="phone.security.length ?? 6"
lock-screen
:reset-key="passcodeResetKey"
:subtitle="
passcodeRetrySeconds > 0
? phone.t('LockScreen.passcode.tryAgain', {
seconds: String(passcodeRetrySeconds),
})
: phone.t('LockScreen.passcode.unlockSubtitle')
"
:title="phone.t('LockScreen.passcode.enter')"
@cancel="cancelPasscode"
@complete="submitUnlockPasscode"
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
/>
</Transition>
<PhoneSetupAssistant
v-if="setupRequired"
@complete="completePhoneSetup"
@skip="skipPhoneSetupForDevelopment"
/>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@open="openNotificationPreview"
/>
<EasyShareSheet />
<div class="phone-display-dimmer" aria-hidden="true"></div>
</k-app>
</div>
<img
class="phone-device__frame"
:src="phoneFrameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
<SkyProvider
class="phone-app-theme"
:dark="phone.isDarkMode"
safe-areas
>
<RouterView v-slot="{ Component }">
<Transition :name="appTransitionName">
<component
:is="Component"
v-if="
!setupRequired && (isAppRoute || isDevelopmentRoute)
"
:key="
isDevelopmentRoute ? String(route.name) : route.path
"
/>
</Transition>
</RouterView>
</SkyProvider>
<PhoneHomeIndicator v-if="!isLocked && !setupRequired" />
<PhoneControlCenter
v-if="!setupRequired"
:opened="controlCenterOpened"
@close="controlCenterOpened = false"
/>
<Transition name="lock-screen" @after-leave="completeUnlock">
<PhoneLockScreen
v-if="isLocked && !setupRequired"
:notifications="notifications.lockScreenNotifications"
@camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="
notifications.dismissFromLockScreen
"
@open-notification="openLockScreenNotification"
@unlock="unlockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible && !setupRequired"
:busy="passcodeBusy"
:disabled="passcodeRetrySeconds > 0"
:error="passcodeError"
:length="phone.security.length ?? 6"
lock-screen
:reset-key="passcodeResetKey"
:subtitle="
passcodeRetrySeconds > 0
? phone.t('LockScreen.passcode.tryAgain', {
seconds: String(passcodeRetrySeconds),
})
: phone.t('LockScreen.passcode.unlockSubtitle')
"
:title="phone.t('LockScreen.passcode.enter')"
@cancel="cancelPasscode"
@complete="submitUnlockPasscode"
/>
</Transition>
<PhoneSetupAssistant
v-if="setupRequired"
@complete="completePhoneSetup"
@skip="skipPhoneSetupForDevelopment"
/>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@open="openNotificationPreview"
/>
<EasyShareSheet />
<div class="phone-display-dimmer" aria-hidden="true"></div>
</k-app>
</div>
<img
class="phone-device__frame"
:src="phoneFrameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
</div>
</div>
</div>
</main>
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const styles = readFileSync(
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
@@ -53,12 +53,35 @@ describe('browser development preview contract', () => {
expect(source).toMatch(
/function changeHardwareAlertVolume\(delta: number\): void \{[\s\S]*?phone\.setAlertVolumes\(volume\)/,
)
expect(styles).toMatch(
expect(mainCss).toMatch(
/\.phone-hardware-button\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?z-index:\s*101;/,
)
expect(styles).toContain('.phone-hardware-button--power')
expect(mainCss).toContain('.phone-hardware-button--power')
expect(source).toContain('class="phone-volume-hud"')
expect(source).toContain('showHardwareVolumeHud()')
expect(styles).toContain('.phone-volume-hud__level')
expect(mainCss).toContain('.phone-volume-hud__level')
})
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
expect(source).toContain('phone-resolution-canvas--primary')
expect(source).toContain("'--phone-rendered-height'")
expect(source).toContain("'--phone-rendered-width'")
expect(mainCss).toMatch(
/\.phone-resolution-canvas\s*\{[^}]*zoom:\s*var\(--phone-zoom, 1\);/s,
)
expect(mainCss).not.toMatch(
/\.phone-resolution-canvas\s*\{[^}]*transform:\s*scale\(/s,
)
})
it('matches the production phone size and bottom-right stage in development', () => {
expect(source).toContain('const PHONE_BASE_SCALE = 0.69 * 1.2')
expect(source).toContain('() => viewportScale.value * PHONE_BASE_SCALE')
expect(source).not.toContain('DEVELOPMENT_PHONE_SCALE')
expect(source).not.toContain("'phone-stage--dev': isDevelopment")
expect(mainCss).toMatch(
/\.phone-stage\s*\{[^}]*place-items:\s*end;[^}]*padding:\s*0 var\(--phone-edge-gap, 24px\) var\(--phone-edge-gap, 24px\) 0;/s,
)
expect(mainCss).not.toContain('.phone-stage--dev')
})
})
+38 -42
View File
@@ -248,10 +248,6 @@ button {
padding: 0 var(--phone-edge-gap, 24px) var(--phone-edge-gap, 24px) 0;
pointer-events: none;
}
.phone-stage--dev {
place-items: center;
padding: 0;
}
.phone-stage--peek .phone-resolution-wrapper--primary .phone-device {
transform: translateY(calc(100% - 190px));
transform-origin: right bottom;
@@ -273,11 +269,21 @@ button {
transform: translateY(calc(100% + var(--phone-edge-gap, 24px)));
}
.phone-resolution-wrapper {
position: relative;
width: var(--phone-rendered-width, 390px);
height: var(--phone-rendered-height, 844px);
}
.phone-resolution-canvas {
position: absolute;
top: 0;
left: 0;
width: 390px;
height: 844px;
zoom: var(--phone-zoom, 1);
}
.phone-stage--landscape .phone-resolution-wrapper--primary {
.phone-stage--landscape .phone-resolution-canvas--primary {
width: 844px;
height: 390px;
}
@@ -2890,25 +2896,8 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible;
}
.home-drag-layer {
position: absolute;
z-index: 70;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.home-drag-layer * {
pointer-events: none !important;
}
.home-drag-ghost {
position: absolute;
top: 0;
left: 0;
margin: 0;
opacity: 1;
transition: none !important;
animation: none !important;
will-change: transform;
.springboard--home-dragging .springboard-page--apps {
overflow: visible;
}
.app-grid {
display: grid;
@@ -2943,9 +2932,6 @@ button {
transition: transform var(--springboard-page-duration)
var(--springboard-page-easing);
}
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove {
position: absolute;
z-index: 4;
@@ -2957,22 +2943,30 @@ button {
border: 0;
border-radius: 50%;
background: none;
color: black;
color: #111;
display: grid;
place-items: center;
cursor: pointer;
text-shadow: none;
}
.app-icon-remove__badge {
box-sizing: border-box;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
padding: 0;
border: 1px solid #ffffff73;
box-shadow: 0 1px 4px #0009;
background: #8e8e93;
color: #000;
border: 1px solid rgb(255 255 255 / 46%);
border-radius: 50%;
display: grid;
place-items: center;
box-shadow: 0 1px 4px rgb(0 0 0 / 46%);
background: rgb(174 174 178 / 88%);
color: #111;
}
.app-icon-remove__badge svg {
display: block;
stroke: currentcolor;
}
.app-icon-item--editing:not(.app-icon-item--dragging) .app-icon-button {
animation: app-icon-wobble 0.15s ease-in-out infinite alternate;
@@ -3123,11 +3117,13 @@ button {
.springboard-edit-done {
position: absolute;
z-index: 12;
top: 45px;
right: 18px;
min-width: 51px;
min-height: 30px;
padding: 0 13px;
top: 14px;
right: 28px;
display: grid;
width: 58px;
height: 30px;
padding: 0;
place-items: center;
border: 1px solid #ffffff38;
border-radius: 999px;
color: white;
@@ -7777,15 +7773,15 @@ button {
.springboard-edit-add {
position: absolute;
z-index: 12;
top: 45px;
left: 18px;
top: 14px;
left: 28px;
display: grid;
width: 34px;
height: 34px;
width: 58px;
height: 30px;
padding: 0;
place-items: center;
border: 1px solid #ffffff38;
border-radius: 50%;
border-radius: 999px;
color: white;
text-shadow: 0 1px 3px #000a;
cursor: pointer;
+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>
+55 -8
View File
@@ -1,13 +1,12 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_INSTALLED_PHONE_APP_IDS,
} from '@/config/apps'
import { DEFAULT_INSTALLED_PHONE_APP_IDS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import {
getHomeFolder,
HOME_GRID_PAGE_SIZE,
HOME_LAYOUT_VERSION,
removeHomeApp,
} from '@/utils/homeLayout'
@@ -112,7 +111,7 @@ describe('app store', () => {
})
expect(apps.claimedApps).toEqual(['external-radio'])
expect(apps.homeLayout.version).toBe(5)
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
expect(apps.homeLayout.grid).toContain('external-radio')
const pageAppCount = apps.homeLayout.grid
.slice(0, HOME_GRID_PAGE_SIZE)
@@ -122,7 +121,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('keeps external app state from current version 5 layouts', () => {
it('keeps external app state from current layouts', () => {
const apps = useAppStoreStore()
apps.hydrate({
@@ -131,7 +130,8 @@ describe('app store', () => {
dock: [],
grid: ['external-radio'],
hidden: [],
version: 5,
pageCount: 1,
version: HOME_LAYOUT_VERSION,
},
launchCounts: { 'external-radio': 3 },
})
@@ -142,7 +142,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('persists a page-aware version 3 to version 5 migration once', () => {
it('persists a page-aware version 3 migration once', () => {
const apps = useAppStoreStore()
const grid = Array.from({ length: 40 }, () => null as string | null)
grid[20] = 'external-page-two'
@@ -152,7 +152,7 @@ describe('app store', () => {
homeLayout: { dock: [], grid, hidden: [], version: 3 },
})
expect(apps.homeLayout.version).toBe(5)
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
expect(apps.homeLayout.grid[20]).not.toBe('external-page-two')
expect(apps.homeLayout.grid[24]).toBe('external-page-two')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
@@ -331,6 +331,53 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('persists widget reflow without pulling apps back a page', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const originalApps = apps.homeLayout.grid.filter(Boolean)
expect(
apps.applyWidgetGridCapacities(
[10, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, 10],
),
).toBe(true)
expect(
apps.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE).filter(Boolean),
).toEqual(originalApps.slice(0, 10))
expect(
apps.homeLayout.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toEqual(originalApps.slice(10))
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('persists and restores an otherwise empty extra page', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.addHomePage()).toBe(true)
expect(apps.homeLayout.pageCount).toBe(2)
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
const persisted = mocks.phone.saveDeviceNamespace.mock.calls[0]?.[1] as {
homeLayout: typeof apps.homeLayout
}
apps.hydrate({
...persisted,
homeLayout: {
...persisted.homeLayout,
grid: persisted.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE),
},
})
expect(apps.homeLayout.pageCount).toBe(2)
expect(apps.homeLayout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
})
it('persists the complete folder lifecycle through store actions', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
+23 -2
View File
@@ -23,6 +23,7 @@ import {
moveHomeApp,
moveHomeAppToGridPage,
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeHomeApp,
restoreHomeApp,
@@ -230,7 +231,10 @@ export const useAppStoreStore = defineStore('app-store', {
? (data.homeLayout as { version?: unknown }).version
: undefined
const supportsPersistedExternalApps =
layoutVersion === 3 || layoutVersion === 4 || layoutVersion === 5
layoutVersion === 3 ||
layoutVersion === 4 ||
layoutVersion === 5 ||
layoutVersion === 6
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
@@ -291,7 +295,8 @@ export const useAppStoreStore = defineStore('app-store', {
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
layoutVersion === 4
layoutVersion === 4 ||
layoutVersion === 5
) {
this.persist()
}
@@ -374,6 +379,22 @@ export const useAppStoreStore = defineStore('app-store', {
this.persist()
return true
},
applyWidgetGridCapacities(
previousCapacities: readonly number[],
nextCapacities: readonly number[],
): boolean {
const next = reflowHomeGridForWidgetChange(
this.homeLayout,
previousCapacities,
nextCapacities,
)
if (!next) return false
if (next !== this.homeLayout) {
this.homeLayout = next
this.persist()
}
return true
},
createHomeFolder(
from: HomeArea,
sourceIndex: number,
+12 -1
View File
@@ -1,7 +1,12 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import type {
WidgetKind,
WidgetLayout,
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import {
addWidget,
createDefaultWidgetLayout,
@@ -18,6 +23,12 @@ export const useWidgetsStore = defineStore('widgets', {
layout: createDefaultWidgetLayout(),
}),
actions: {
applyLayout(layout: WidgetLayout): boolean {
if (layout === this.layout) return false
this.layout = layout
this.persist()
return true
},
add(kind: WidgetKind, size: WidgetSize, page: number): string | null {
const previousIds = new Set(
this.layout.instances.map((instance) => instance.id),
+88 -8
View File
@@ -12,6 +12,7 @@ import {
HOME_GRID_COLUMNS,
HOME_GRID_PAGE_SIZE,
HOME_GRID_ROWS,
HOME_LAYOUT_VERSION,
homeKeyboardTarget,
isHomeFolder,
MAX_HOME_GRID_PAGES,
@@ -19,6 +20,7 @@ import {
moveHomeAppToGridPage,
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -52,7 +54,8 @@ function pageLayout(pageCounts: readonly number[]): HomeLayout {
dock: [generatedApp(999), null, null, null],
grid,
hidden: [],
version: 5,
pageCount: pageCounts.length,
version: HOME_LAYOUT_VERSION,
}
}
@@ -72,7 +75,7 @@ describe('home layout', () => {
null,
])
expect(layout.dock).toEqual(['phone', 'messages', 'clock', null])
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
@@ -89,7 +92,7 @@ describe('home layout', () => {
expect(layout.dock).toEqual(['messages', null, null, null])
expect(layout.grid.slice(0, 4)).toEqual(['mail', 'clock', 'notes', null])
expect(layout.hidden).toEqual(['phone'])
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('closes gaps within a versioned page without moving apps between pages', () => {
@@ -127,7 +130,7 @@ describe('home layout', () => {
.slice(0, HOME_GRID_PAGE_SIZE)
.filter((item) => item !== null).length
expect(layout.grid.slice(0, pageItemCount)).not.toContain(null)
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves page membership while expanding version 3 pages', () => {
@@ -147,7 +150,7 @@ describe('home layout', () => {
expect(layout.grid.slice(0, 3)).toEqual(['phone', 'messages', 'notes'])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('mail')
expect(layout.grid[HOME_GRID_PAGE_SIZE + 1]).toBe('clock')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('reads version 4 pages directly before migrating the schema', () => {
@@ -167,7 +170,7 @@ describe('home layout', () => {
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(layout.grid[0]).toBe('phone')
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('messages')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves folders and page membership in version 5 layouts', () => {
@@ -194,7 +197,7 @@ describe('home layout', () => {
'notes',
])
expect(layout.grid[HOME_GRID_PAGE_SIZE]).toBe('clock')
expect(layout.version).toBe(5)
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('preserves independently positioned shortcuts for the same app', () => {
@@ -323,6 +326,61 @@ describe('home layout', () => {
)
})
it('materializes the visible pages before moving a widget forward', () => {
const layout = pageLayout([0, HOME_GRID_PAGE_SIZE, 7])
const pageTwoApps = layout.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter((item) => item !== null)
const pageThreeApps = layout.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter((item) => item !== null)
const reflowed = reflowHomeGridForWidgetChange(
layout,
[HOME_GRID_PAGE_SIZE, 20, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE, 20],
)
expect(reflowed).not.toBeNull()
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toEqual(pageTwoApps.slice(0, 20))
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter(Boolean),
).toEqual([...pageTwoApps.slice(20), ...pageThreeApps])
})
it('moves widget overflow only to later pages', () => {
const layout = pageLayout([0, HOME_GRID_PAGE_SIZE, 20])
const allApps = layout.grid.filter(Boolean).sort()
const reflowed = reflowHomeGridForWidgetChange(
layout,
[HOME_GRID_PAGE_SIZE, 20, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE, 20],
)
expect(reflowed).not.toBeNull()
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toHaveLength(20)
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 2, HOME_GRID_PAGE_SIZE * 3)
.filter(Boolean),
).toHaveLength(20)
expect(
reflowed?.grid
.slice(HOME_GRID_PAGE_SIZE * 3, HOME_GRID_PAGE_SIZE * 4)
.filter(Boolean),
).toHaveLength(4)
expect(reflowed?.grid.filter(Boolean).sort()).toEqual(allApps)
})
it('provides bounded keyboard reorder targets without wrapping rows', () => {
expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2)
expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull()
@@ -423,15 +481,37 @@ describe('home layout', () => {
}
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
expect(layout.pageCount).toBe(MAX_HOME_GRID_PAGES)
expect(addHomePage(layout)).toBe(layout)
})
it('restores an explicitly persisted empty trailing page', () => {
const layout = parseHomeLayout(
{
dock: defaults.dock,
grid: defaults.grid.slice(0, 5),
hidden: [],
pageCount: 2,
version: HOME_LAYOUT_VERSION,
},
defaults,
[...installed],
)
expect(layout.pageCount).toBe(2)
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
expect(
layout.grid.slice(HOME_GRID_PAGE_SIZE).every((item) => item === null),
).toBe(true)
})
it('deletes a page and moves its items into remaining empty slots', () => {
let layout = addHomePage(defaults)
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
const deleted = deleteHomePage(layout, 2)
expect(deleted.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
expect(deleted.pageCount).toBe(1)
expect(deleted.grid).toContain('phone')
expect(deleteHomePage(deleted, 1)).toBe(deleted)
})
@@ -462,7 +542,7 @@ describe('home layout', () => {
' Dienstprogramme ',
)
const parsed = parseHomeLayout(renamed, defaults, [...installed])
expect(parsed.version).toBe(5)
expect(parsed.version).toBe(HOME_LAYOUT_VERSION)
expect(getHomeFolder(parsed, 'folder-work-123456')).toEqual({
apps: ['notes', 'mail'],
id: 'folder-work-123456',
+92 -9
View File
@@ -8,7 +8,7 @@ export const HOME_GRID_PAGE_SIZE = HOME_GRID_COLUMNS * HOME_GRID_ROWS
export const HOME_FOLDER_PAGE_SIZE = 9
export const HOME_FOLDER_NAME_MAX_LENGTH = 32
export const MAX_HOME_GRID_PAGES = 5
export const HOME_LAYOUT_VERSION = 5
export const HOME_LAYOUT_VERSION = 6
const LEGACY_HOME_GRID_PAGE_SIZE = 20
export type HomeArea = 'dock' | 'grid'
@@ -27,6 +27,7 @@ export type HomeLayout = {
dock: HomeSlot[]
grid: HomeSlot[]
hidden: LaunchablePhoneAppId[]
pageCount: number
version: typeof HOME_LAYOUT_VERSION
}
@@ -90,10 +91,19 @@ function cloneLayout(layout: HomeLayout): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: layout.grid.map(cloneItem),
hidden: [...layout.hidden],
pageCount: layout.pageCount,
version: HOME_LAYOUT_VERSION,
}
}
function clampPageCount(value: number): number {
return Math.max(1, Math.min(MAX_HOME_GRID_PAGES, Math.trunc(value)))
}
function inferredPageCount(slots: readonly HomeSlot[]): number {
return clampPageCount(Math.ceil(slots.length / HOME_GRID_PAGE_SIZE))
}
function normalizeFolder(folder: HomeFolder): HomeSlot {
if (folder.apps.length === 0) return null
if (folder.apps.length === 1) return folder.apps[0]
@@ -354,6 +364,43 @@ function serializeGridPages(pages: readonly IndexedHomeItem[][]): HomeSlot[] {
return slots
}
export function reflowHomeGridForWidgetChange(
layout: HomeLayout,
previousCapacities: HomeGridPageCapacities,
nextCapacities: HomeGridPageCapacities,
): HomeLayout | null {
const currentPages = distributeGridPages(
layout.grid,
previousCapacities,
layout.pageCount,
)
if (!currentPages) return null
const materializedGrid = serializeGridPages(currentPages)
const nextPages = distributeGridPages(
materializedGrid,
nextCapacities,
Math.max(layout.pageCount, currentPages.length),
)
if (!nextPages) return null
const nextGrid = serializeGridPages(nextPages)
const nextPageCount = clampPageCount(
Math.max(layout.pageCount, nextPages.length),
)
if (
nextPageCount === layout.pageCount &&
JSON.stringify(nextGrid) === JSON.stringify(layout.grid)
) {
return layout
}
const next = cloneLayout(layout)
next.grid = nextGrid
next.pageCount = nextPageCount
return next
}
export function moveHomeAppToGridPage(
layout: HomeLayout,
from: HomeArea,
@@ -416,6 +463,9 @@ export function moveHomeAppToGridPage(
const next = cloneLayout(layout)
next.grid = serializeGridPages(pages)
next.pageCount = clampPageCount(
Math.max(layout.pageCount, targetPage, pages.length),
)
if (from === 'dock') next.dock[sourceIndex] = null
if (
JSON.stringify(next.grid) === JSON.stringify(layout.grid) &&
@@ -446,7 +496,13 @@ export function createDefaultHomeLayout(
dock[index] = id
}
return { dock, grid, hidden: [], version: HOME_LAYOUT_VERSION }
return {
dock,
grid,
hidden: [],
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
export function parseHomeLayout(
@@ -461,13 +517,19 @@ export function parseHomeLayout(
const availableIds = new Set(installedIds)
if (
preservePersistedIds &&
(source.version === 3 || source.version === 4 || source.version === 5)
(source.version === 3 ||
source.version === 4 ||
source.version === 5 ||
source.version === 6)
) {
for (const collection of [source.dock, source.grid, source.hidden]) {
if (!Array.isArray(collection)) continue
for (const item of collection) {
if (isPersistableAppId(item)) availableIds.add(item)
if (source.version === 5 && isHomeFolder(item)) {
if (
(source.version === 5 || source.version === 6) &&
isHomeFolder(item)
) {
for (const appId of item.apps) {
if (isPersistableAppId(appId)) availableIds.add(appId)
}
@@ -488,8 +550,15 @@ export function parseHomeLayout(
) * HOME_GRID_PAGE_SIZE
: Math.min(source.grid.length, HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
: 0
const persistedPageCount =
source.version === 6 &&
typeof source.pageCount === 'number' &&
Number.isInteger(source.pageCount)
? clampPageCount(source.pageCount)
: clampPageCount(Math.ceil(persistedGridLength / HOME_GRID_PAGE_SIZE))
const gridLength = Math.max(
defaults.grid.length,
persistedPageCount * HOME_GRID_PAGE_SIZE,
getGridCapacity(persistedGridLength),
)
let grid: HomeSlot[]
@@ -507,9 +576,13 @@ export function parseHomeLayout(
new Set(),
false,
)
} else if (source.version === 4 || source.version === 5) {
} else if (
source.version === 4 ||
source.version === 5 ||
source.version === 6
) {
const folderIds = new Set<string>()
const allowFolders = source.version === 5
const allowFolders = source.version === 5 || source.version === 6
grid = readSlots(
source.grid,
availableIds,
@@ -567,6 +640,7 @@ export function parseHomeLayout(
dock: dock.map(cloneItem),
grid: compactGridPages(grid),
hidden,
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
@@ -589,6 +663,7 @@ export function removeHomeApp(
hidden: layout.hidden.includes(appId)
? [...layout.hidden]
: [...layout.hidden, appId],
pageCount: layout.pageCount,
version: HOME_LAYOUT_VERSION,
}
}
@@ -613,25 +688,32 @@ export function restoreHomeApp(
dock: layout.dock.map(cloneItem),
grid: compactGridPages(grid),
hidden: layout.hidden.filter((id) => id !== appId),
pageCount: inferredPageCount(grid),
version: HOME_LAYOUT_VERSION,
}
}
export function addHomePage(layout: HomeLayout): HomeLayout {
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
if (layout.pageCount >= MAX_HOME_GRID_PAGES) {
return layout
}
const pageCount = layout.pageCount + 1
const requiredGridLength = pageCount * HOME_GRID_PAGE_SIZE
return {
dock: layout.dock.map(cloneItem),
grid: [...layout.grid.map(cloneItem), ...createSlots(HOME_GRID_PAGE_SIZE)],
grid: [
...layout.grid.map(cloneItem),
...createSlots(Math.max(0, requiredGridLength - layout.grid.length)),
],
hidden: [...layout.hidden],
pageCount,
version: HOME_LAYOUT_VERSION,
}
}
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
const pageCount = layout.pageCount
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
@@ -648,6 +730,7 @@ export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
dock: layout.dock.map(cloneItem),
grid: compactGridPages(grid),
hidden: [...layout.hidden],
pageCount: pageCount - 1,
version: HOME_LAYOUT_VERSION,
}
}
+75 -6
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
maximumRenderedWidgetPage,
nearestSpringboardRectIndex,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardEdgeDirection,
@@ -18,6 +19,17 @@ describe('springboard widget drag', () => {
expect(springboardEdgeDirection(284, 100, 468)).toBe(0)
})
it('keeps both phone edges reachable at normal and reduced FiveM scales', () => {
expect(springboardEdgeDirection(125, 100, 468)).toBe(-1)
expect(springboardEdgeDirection(150, 100, 468)).toBe(0)
expect(springboardEdgeDirection(420, 100, 468)).toBe(0)
expect(springboardEdgeDirection(443, 100, 468)).toBe(1)
expect(springboardEdgeDirection(117, 100, 247)).toBe(-1)
expect(springboardEdgeDirection(200, 100, 247)).toBe(0)
expect(springboardEdgeDirection(230, 100, 247)).toBe(1)
expect(springboardEdgeDirection(100, 100, 100)).toBe(0)
})
it('resolves reachable page destinations without crossing page bounds', () => {
expect(resolveSpringboardEdgeTurn(455, 100, 468, 1, 0, 3)).toEqual({
destination: 2,
@@ -31,6 +43,34 @@ describe('springboard widget drag', () => {
expect(resolveSpringboardEdgeTurn(455, 100, 468, 3, 0, 3)).toBeNull()
})
it('resolves consecutive edge turns while an app remains at the same edge', () => {
const firstTurn = resolveSpringboardHomeEdgeTurn(
113,
100,
468,
4,
4,
5,
false,
)
expect(firstTurn).toEqual({
destination: 3,
direction: -1,
previewsPage: false,
})
expect(
resolveSpringboardHomeEdgeTurn(
113,
100,
468,
firstTurn?.destination ?? 4,
4,
5,
false,
),
).toEqual({ destination: 2, direction: -1, previewsPage: false })
})
it('previews one new trailing page without crossing the home-page limit', () => {
expect(
resolveSpringboardHomeEdgeTurn(455, 100, 468, 2, 2, 5, false),
@@ -60,15 +100,44 @@ describe('springboard widget drag', () => {
expect(springboardPageDragCompensation(2, 2, 368)).toBe(0)
})
it('selects drop slots directly in viewport order', () => {
const slots = [
{ height: 80, left: 100, top: 150, width: 70 },
{ height: 80, left: 180, top: 150, width: 70 },
{ height: 80, left: 260, top: 150, width: 70 },
]
expect(nearestSpringboardRectIndex(115, 190, slots)).toBe(0)
expect(nearestSpringboardRectIndex(300, 190, slots)).toBe(2)
expect(nearestSpringboardRectIndex(220, 190, slots)).toBe(1)
expect(nearestSpringboardRectIndex(220, 190, [])).toBeNull()
})
it('converts viewport coordinates into local phone coordinates under zoom', () => {
expect(
springboardViewportToLocal(169, 238, 100, 100, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
const point = springboardViewportToLocal(
169,
238,
100,
100,
253.92,
582.36,
368,
844,
)
expect(point.x).toBeCloseTo(100)
expect(point.y).toBeCloseTo(200)
expect(
springboardViewportToLocal(200, 250, 100, 50, 0, 0, 368, 844),
).toEqual({ x: 100, y: 200 })
expect(
springboardViewportDeltaToLocal(69, 138, 253.92, 582.36, 368, 844),
).toEqual({ x: 100, y: 200 })
const delta = springboardViewportDeltaToLocal(
69,
138,
253.92,
582.36,
368,
844,
)
expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200)
})
})
+57 -1
View File
@@ -16,6 +16,40 @@ export type SpringboardLocalPoint = {
y: number
}
export type SpringboardViewportRect = {
height: number
left: number
top: number
width: number
}
export type SpringboardDragMetrics = {
layoutHeight: number
layoutWidth: number
viewportHeight: number
viewportLeft: number
viewportTop: number
viewportWidth: number
}
export function readSpringboardDragMetrics(
element: Element | null,
): SpringboardDragMetrics | null {
const surface = element?.closest<HTMLElement>(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds = surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
viewportHeight: bounds.height,
viewportLeft: bounds.left,
viewportTop: bounds.top,
viewportWidth: bounds.width,
}
}
export function springboardViewportToLocal(
clientX: number,
clientY: number,
@@ -55,6 +89,27 @@ export function springboardPageDragCompensation(
return (currentPage - startPage) * Math.max(0, pageWidth)
}
export function nearestSpringboardRectIndex(
clientX: number,
clientY: number,
rects: readonly SpringboardViewportRect[],
): number | null {
if (!rects.length) return null
let nearestIndex = 0
let nearestDistance = Number.POSITIVE_INFINITY
for (const [index, rect] of rects.entries()) {
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const distance = Math.hypot(clientX - centerX, clientY - centerY)
if (distance < nearestDistance) {
nearestDistance = distance
nearestIndex = index
}
}
return nearestIndex
}
export function springboardSwipeIntent(
deltaX: number,
deltaY: number,
@@ -70,7 +125,8 @@ export function springboardEdgeDirection(
right: number,
): PageTurnDirection {
const width = Math.max(0, right - left)
const edgeSize = Math.min(42, width * 0.12)
if (width === 0) return 0
const edgeSize = Math.min(48, Math.max(28, width * 0.12))
if (clientX <= left + edgeSize) return -1
if (clientX >= right - edgeSize) return 1
return 0
@@ -6,6 +6,7 @@ const viewSource = readFileSync(
new URL('./SpringboardView.vue', import.meta.url),
'utf8',
)
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const appIconSource = readFileSync(
new URL('../components/AppIcon.vue', import.meta.url),
'utf8',
@@ -19,6 +20,26 @@ const mainCss = readFileSync(
'utf8',
)
describe('Springboard page swipe contract', () => {
it('replaces the home status row with the edit controls', () => {
expect(viewSource).toContain("emit('editModeChange', editing)")
expect(viewSource).toContain("emit('editModeChange', false)")
expect(appSource).toContain(
'@edit-mode-change="springboardEditing = $event"',
)
expect(appSource).toMatch(
/v-if="\s*!isLocked && !\(isHomeRoute && springboardEditing\)\s*"/,
)
expect(mainCss).toMatch(
/\.springboard-edit-add\s*\{[^}]*top:\s*14px;[^}]*left:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
)
expect(mainCss).toMatch(
/\.springboard-edit-done\s*\{[^}]*top:\s*14px;[^}]*right:\s*28px;[^}]*width:\s*58px;[^}]*height:\s*30px;/s,
)
expect(viewSource).toContain(
'<Check :size="20" :stroke-width="3" aria-hidden="true" />',
)
})
it('allows page swipes to start on home apps and widget surfaces', () => {
expect(viewSource).toContain(
'.springboard-page--apps .app-icon-button, .home-widget',
@@ -29,6 +50,15 @@ describe('Springboard page swipe contract', () => {
)
})
it('keeps blank-surface page swipes available while editing', () => {
expect(viewSource).not.toMatch(
/if \(editMode\.value\) return\s+dragging\.value/,
)
expect(viewSource).toContain(
'if (editMode.value || pageSwipeSurface) return',
)
})
it('suppresses app and folder activation after pointer movement', () => {
expect(appIconSource).toContain('springboardSwipeIntent')
expect(folderIconSource).toContain('springboardSwipeIntent')
@@ -45,42 +75,31 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).not.toContain('@lostpointercapture')
})
it('separates animated page compensation from immediate pointer movement', () => {
it('converts pointer movement from viewport pixels using measured page geometry', () => {
expect(appIconSource).toContain('springboardPageDragCompensation')
expect(folderIconSource).toContain('springboardPageDragCompensation')
expect(appIconSource).toContain('readSpringboardDragMetrics')
expect(folderIconSource).toContain('readSpringboardDragMetrics')
expect(appIconSource).toContain(':style="dragPointerStyle"')
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('keeps a phone-wide drag ghost outside the moving page track', () => {
const trackStart = viewSource.indexOf('class="springboard-track"')
const dragLayerStart = viewSource.indexOf('ref="homeDragLayer"')
expect(trackStart).toBeGreaterThan(-1)
expect(dragLayerStart).toBeGreaterThan(trackStart)
expect(viewSource.slice(trackStart, dragLayerStart)).toContain('</div>')
expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).toContain("ghost.classList.add('home-drag-ghost')")
expect(viewSource).toContain('springboardViewportToLocal')
expect(viewSource).toContain('springboardViewportDeltaToLocal')
expect(viewSource).toContain('event.target instanceof Element')
expect(viewSource).toContain('dropGhost?.getBoundingClientRect()')
expect(viewSource.match(/:external-drag-visual/g)).toHaveLength(4)
expect(viewSource.match(/startHomeDrag\([^\n]+\$event\)/g)).toHaveLength(4)
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(folderIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain("'app-icon-item--drag-source'")
expect(folderIconSource).toContain("'app-icon-item--drag-source'")
expect(mainCss).toMatch(
/\.home-drag-layer\s*\{[^}]*z-index:\s*70;[^}]*pointer-events:\s*none;/s,
it('uses the same scale-aware in-phone drag path as widgets', () => {
expect(viewSource).not.toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null",
)
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
expect(appIconSource).not.toContain('externalDragVisual')
expect(folderIconSource).not.toContain('externalDragVisual')
expect(mainCss).not.toContain('.home-drag-layer')
expect(mainCss).not.toContain('.home-drag-ghost')
expect(mainCss).not.toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(
/\.home-drag-ghost\s*\{[^}]*position:\s*absolute;[^}]*will-change:\s*transform;/s,
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
)
expect(mainCss).toMatch(
/\.app-icon-item--drag-source\s*\{[^}]*opacity:\s*0;/s,
)
expect(mainCss).not.toContain('.springboard--home-dragging')
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
@@ -93,8 +112,11 @@ describe('Springboard page swipe contract', () => {
expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
expect(viewSource).toContain('event.clientX < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
})
it('resolves occupied dock slots from the dock bounds rather than the event target', () => {
@@ -111,4 +133,15 @@ describe('Springboard page swipe contract', () => {
expect(viewSource).toContain('resolveSpringboardHomeEdgeTurn')
expect(viewSource).toContain('onBeforeUnmount')
})
it('materializes page-local app positions before widget layout changes', () => {
expect(viewSource).toContain('function applyWidgetLayout')
expect(viewSource).toContain('appStore.applyWidgetGridCapacities')
expect(viewSource).toContain('homeGridCapacitiesFor(widgets.layout)')
expect(viewSource).toContain('homeGridCapacitiesFor(nextLayout)')
expect(viewSource).toContain('previewWidgetAdd')
expect(viewSource).toContain('previewWidgetMove')
expect(viewSource).toContain('previewWidgetRemove')
expect(viewSource).toContain('previewWidgetResize')
})
})
+235 -297
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
import { Check, Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
import { kButton, kGlass } from 'konsta/vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -15,7 +15,19 @@ import { usePhoneStore } from '@/stores/phone'
import { useWidgetsStore } from '@/stores/widgets'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
import type {
WidgetKind,
WidgetLayout,
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import {
SkyActionButton,
SkyActionGroup,
SkyActionSheet,
SkyActionsLabel,
SkyProvider,
} from '@/ui'
import {
deleteHomePage as previewHomePageDelete,
getHomeFolder,
@@ -32,15 +44,18 @@ import type { ReorderDirection } from '@/utils/keyboard'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
nearestSpringboardRectIndex,
resolveSpringboardEdgeTurn,
resolveSpringboardHomeEdgeTurn,
springboardSwipeIntent,
springboardViewportDeltaToLocal,
springboardViewportToLocal,
} from '@/utils/springboardDrag'
import {
addWidget as previewWidgetAdd,
deleteWidgetPage as previewWidgetPageDelete,
moveWidget as previewWidgetMove,
removeWidget as previewWidgetRemove,
resizeWidget as previewWidgetResize,
WIDGET_GRID_COLUMNS,
widgetKeyboardTarget,
widgetOccupiedCells,
@@ -54,6 +69,10 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'shopping',
]
const HOME_FOLDER_HOVER_DURATION = 900
const HOME_EDGE_PAGE_TURN_DELAY = 420
const emit = defineEmits<{
editModeChange: [editing: boolean]
}>()
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
@@ -87,10 +106,9 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false)
const renameFolderOnOpenId = ref<string | null>(null)
const folderHoverTarget = ref('')
let pointerStart = 0
@@ -107,8 +125,6 @@ let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null
let homeDragGhost: HTMLElement | null = null
let homeDragGrip: { x: number; y: number } | null = null
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -121,6 +137,17 @@ const hiddenApps = computed(() =>
appStore.homeLayout.hidden.includes(app.id),
),
)
function homeGridCapacitiesFor(layout: WidgetLayout): number[] {
return Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
Math.max(
0,
HOME_GRID_PAGE_SIZE -
widgetOccupiedCells(layout.instances, pageIndex + 1).size,
),
)
}
const previewWidgetLayout = computed(() => {
const preview = widgetDragPreview.value
return preview
@@ -133,24 +160,13 @@ const previewWidgetLayout = computed(() => {
)
: widgets.layout
})
const homeGridCapacities = computed(() =>
Array.from({ length: MAX_HOME_GRID_PAGES }, (_, pageIndex) =>
Math.max(
0,
HOME_GRID_PAGE_SIZE -
widgetOccupiedCells(widgets.layout.instances, pageIndex + 1).size,
),
),
)
const homeGridCapacities = computed(() => homeGridCapacitiesFor(widgets.layout))
const appPages = computed(() => {
const maxWidgetPage = maximumRenderedWidgetPage(
widgets.layout.instances.map((instance) => instance.page),
previewWidgetLayout.value.instances.map((instance) => instance.page),
)
const persistedHomePages = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
)
const persistedHomePages = appStore.homeLayout.pageCount
const lastHomePage = Math.max(
maxWidgetPage,
persistedHomePages,
@@ -210,13 +226,10 @@ const isEditablePage = computed(
() => phone.currentPage >= 0 && phone.currentPage < libraryPage.value,
)
const canAddHomePage = computed(
() =>
appStore.homeLayout.grid.length < HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
() => appStore.homeLayout.pageCount < MAX_HOME_GRID_PAGES,
)
const addingHomePage = ref(false)
const persistedHomePageCount = computed(() =>
Math.max(1, Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE)),
)
const persistedHomePageCount = computed(() => appStore.homeLayout.pageCount)
const canDeleteCurrentPage = computed(() => {
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
const remainingPages = persistedHomePageCount.value - 1
@@ -245,6 +258,9 @@ const openedFolder = computed(() =>
? getHomeFolder(appStore.homeLayout, openedFolderId.value)
: null,
)
const folderOverlayVisible = computed(
() => openedFolder.value !== null && !folderDraggingOutside.value,
)
const openedFolderApps = computed(() =>
openedFolder.value
? openedFolder.value.apps.flatMap((appId, index) => {
@@ -365,13 +381,12 @@ function onPointerDown(event: PointerEvent): void {
pointerStartedAt = Date.now()
pointerPageStart = phone.currentPage
blankTapCandidate = editMode.value
if (editMode.value) return
dragging.value = true
if (!pageSwipeSurface) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
clearBackgroundHold()
if (pageSwipeSurface) return
if (editMode.value || pageSwipeSurface) return
backgroundHoldTimer = window.setTimeout(() => {
enterEditMode()
backgroundHoldTimer = undefined
@@ -458,22 +473,22 @@ function updateWidgetDragPreview(event: {
`[data-widget-page="${page}"]`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return
if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect()
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
const gridLeft =
springboardBounds.left + (renderedGridBounds.left - pageBounds.left)
const gridStyle = getComputedStyle(grid)
const columnGap = Number.parseFloat(gridStyle.columnGap) || 0
const rowGap = Number.parseFloat(gridStyle.rowGap) || 0
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
const scaleY =
grid.offsetHeight > 0 ? renderedGridBounds.height / grid.offsetHeight : 1
const columnGap = (Number.parseFloat(gridStyle.columnGap) || 0) * scaleX
const rowGap = (Number.parseFloat(gridStyle.rowGap) || 0) * scaleY
const columnWidth =
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
WIDGET_GRID_COLUMNS
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows) * scaleY
const column = Math.round(
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
(event.clientX - grip.x - renderedGridBounds.left) /
(columnWidth + columnGap),
)
const row = Math.round(
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
@@ -505,7 +520,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
if (keepCurrentPage) return
const lastPage = Math.max(
1,
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
appStore.homeLayout.pageCount,
...widgets.layout.instances.map((instance) => instance.page),
)
if (phone.currentPage > lastPage) changePage(lastPage)
@@ -528,7 +543,7 @@ function resolveHomeEdgeTurn(event: { clientX: number }) {
}
function queueEdgePageTurn(
event: PointerEvent,
event: { clientX: number; clientY: number },
dragType: 'app' | 'widget',
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
@@ -582,8 +597,11 @@ function queueEdgePageTurn(
}
edgePageTimer = undefined
edgePageDirection = direction
edgePageLocked = true
}, 520)
edgePageLocked = dragType === 'widget'
if (dragType === 'app' && lastHomePointer && draggingHomeApp.value) {
queueEdgePageTurn(lastHomePointer, 'app')
}
}, HOME_EDGE_PAGE_TURN_DELAY)
}
function queueWidgetDragPreview(event: PointerEvent): void {
@@ -611,6 +629,19 @@ function clearWidgetDragPreview(): void {
widgetDragPreview.value = null
}
function applyWidgetLayout(nextLayout: WidgetLayout): boolean {
if (nextLayout === widgets.layout) return true
if (
!appStore.applyWidgetGridCapacities(
homeGridCapacitiesFor(widgets.layout),
homeGridCapacitiesFor(nextLayout),
)
) {
return false
}
return widgets.applyLayout(nextLayout)
}
function finishWidgetDrag(event: PointerEvent): void {
const id = draggingWidgetId.value
if (!id) return
@@ -618,7 +649,15 @@ function finishWidgetDrag(event: PointerEvent): void {
updateWidgetDragPreview(lastWidgetPointer)
const preview = widgetDragPreview.value
if (preview?.id === id) {
widgets.move(id, preview.page, preview.column, preview.row)
applyWidgetLayout(
previewWidgetMove(
widgets.layout,
id,
preview.page,
preview.column,
preview.row,
),
)
}
draggingWidgetId.value = null
clearWidgetDragPreview()
@@ -634,18 +673,33 @@ function reorderWidget(id: string, direction: ReorderDirection): void {
if (!instance) return
const target = widgetKeyboardTarget(instance, direction)
if (!target) return
widgets.move(id, instance.page, target.column, target.row)
applyWidgetLayout(
previewWidgetMove(
widgets.layout,
id,
instance.page,
target.column,
target.row,
),
)
}
function removeWidget(id: string): void {
widgets.remove(id)
if (!applyWidgetLayout(previewWidgetRemove(widgets.layout, id))) return
if (widgetActionId.value === id) widgetActionId.value = null
if (widgetConfigId.value === id) widgetConfigId.value = null
}
async function addWidget(kind: WidgetKind, size: WidgetSize): Promise<void> {
const targetPage = isEditablePage.value ? phone.currentPage : 1
const addedId = widgets.add(kind, size, targetPage)
const previousIds = new Set(
widgets.layout.instances.map((instance) => instance.id),
)
const nextLayout = previewWidgetAdd(widgets.layout, kind, size, targetPage)
if (nextLayout === widgets.layout || !applyWidgetLayout(nextLayout)) return
const addedId = widgets.layout.instances.find(
(instance) => !previousIds.has(instance.id),
)?.id
if (!addedId) return
widgetPickerOpened.value = false
editMode.value = true
@@ -667,7 +721,8 @@ async function saveWidgetConfig(
): Promise<void> {
if (!widgetConfigId.value) return
const id = widgetConfigId.value
widgets.resize(id, size)
const resized = previewWidgetResize(widgets.layout, id, size)
if (!applyWidgetLayout(resized)) return
widgets.updateSettings(id, settings)
widgetConfigId.value = null
editMode.value = true
@@ -678,122 +733,10 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
function homeDragLocalPoint(
clientX: number,
clientY: number,
): {
x: number
y: number
} | null {
const layer = homeDragLayer.value
if (!layer) return null
const bounds = layer.getBoundingClientRect()
return springboardViewportToLocal(
clientX,
clientY,
bounds.left,
bounds.top,
bounds.width,
bounds.height,
layer.offsetWidth,
layer.offsetHeight,
)
}
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
if (expectedGhost && homeDragGhost !== expectedGhost) return
homeDragGhost?.remove()
homeDragGhost = null
homeDragGrip = null
homeDragVisualActive.value = false
}
function updateHomeDragGhost(event: {
clientX: number
clientY: number
}): void {
const point = homeDragLocalPoint(event.clientX, event.clientY)
if (!homeDragGhost || !homeDragGrip || !point) return
homeDragGhost.style.transform = `translate3d(${point.x - homeDragGrip.x}px, ${point.y - homeDragGrip.y}px, 0)`
}
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
const layer = homeDragLayer.value
if (!source || !layer) return
const layerBounds = layer.getBoundingClientRect()
const sourceBounds = source.getBoundingClientRect()
const sourceOrigin = springboardViewportToLocal(
sourceBounds.left,
sourceBounds.top,
layerBounds.left,
layerBounds.top,
layerBounds.width,
layerBounds.height,
layer.offsetWidth,
layer.offsetHeight,
)
const pointer = homeDragLocalPoint(event.clientX, event.clientY)
if (!pointer) return
const ghost = source.cloneNode(true) as HTMLElement
ghost.classList.remove(
'app-icon-item--dragging',
'app-icon-item--editing',
'app-icon-item--drag-source',
'app-icon-item--drop-settling',
'home-folder-item--dragging',
'home-item--folder-hover',
)
ghost.classList.add('home-drag-ghost')
ghost.removeAttribute('style')
ghost.setAttribute('aria-hidden', 'true')
ghost.querySelector('.app-icon-remove')?.remove()
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [tabindex]',
)) {
control.tabIndex = -1
}
const localScaleX =
layerBounds.width > 0 ? layer.offsetWidth / layerBounds.width : 1
const localScaleY =
layerBounds.height > 0 ? layer.offsetHeight / layerBounds.height : 1
ghost.style.width = `${sourceBounds.width * localScaleX}px`
ghost.style.height = `${sourceBounds.height * localScaleY}px`
homeDragGhost = ghost
homeDragGrip = {
x: pointer.x - sourceOrigin.x,
y: pointer.y - sourceOrigin.y,
}
layer.appendChild(ghost)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
}
function startHomeDrag(
area: HomeArea,
index: number,
event: PointerEvent,
): void {
function startHomeDrag(area: HomeArea, index: number): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -879,7 +822,6 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -890,7 +832,6 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
@@ -983,35 +924,25 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
const springboardBounds = springboard.getBoundingClientRect()
if (
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
) {
return null
}
const slots = Array.from(
grid.querySelectorAll<HTMLElement>('[data-home-target-offset]'),
)
const distanceToSlot = (slot: HTMLElement): number => {
const bounds = slot.getBoundingClientRect()
const centerX =
springboardBounds.left +
(bounds.left - pageBounds.left) +
bounds.width / 2
const centerY =
springboardBounds.top + (bounds.top - pageBounds.top) + bounds.height / 2
return Math.hypot(event.clientX - centerX, event.clientY - centerY)
}
const closest = slots.reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
return distanceToSlot(slot) < distanceToSlot(nearest) ? slot : nearest
}, null)
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
return Number.isInteger(targetOffset) ? { page, targetOffset } : null
}
@@ -1031,37 +962,29 @@ function nearestDockDropTarget(event: {
) {
return null
}
return Array.from(
const slots = Array.from(
dock.querySelectorAll<HTMLElement>(
'[data-home-area="dock"][data-home-index]',
),
).reduce<HTMLElement | null>((nearest, slot) => {
if (!nearest) return slot
const bounds = slot.getBoundingClientRect()
const nearestBounds = nearest.getBoundingClientRect()
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
)
const nearestDistance = Math.hypot(
event.clientX - (nearestBounds.left + nearestBounds.width / 2),
event.clientY - (nearestBounds.top + nearestBounds.height / 2),
)
return distance < nearestDistance ? slot : nearest
}, null)
)
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
)
return closestIndex === null ? null : slots[closestIndex]
}
function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) {
clearHomeDragGhost()
return
}
if (!dragged) return
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const dropGhost = homeDragGhost
const dropOrigin = dropGhost?.getBoundingClientRect()
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1099,14 +1022,7 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
}
}
@@ -1115,7 +1031,6 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1184,10 +1099,12 @@ function folderPreviewApps(folder: HomeFolder): PhoneAppDefinition[] {
function openFolder(folderId: string): void {
pageTransitioning.value = false
folderDraggingOutside.value = false
openedFolderId.value = folderId
}
function closeFolder(): void {
folderDraggingOutside.value = false
openedFolderId.value = null
renameFolderOnOpenId.value = null
}
@@ -1209,9 +1126,11 @@ function extractOpenedFolderApp(
const folder = openedFolder.value
if (!folder) return
const appId = folder.apps[sourceIndex]
const sourceElement = (event.currentTarget as HTMLElement | null)?.closest(
'.app-icon-item',
)
const sourceElement = document
.querySelector<HTMLElement>(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect()
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
@@ -1238,15 +1157,20 @@ function extractOpenedFolderApp(
)
return distance < closestDistance ? candidate : closest
}, null)
if (!target) return
if (!target) {
closeFolder()
return
}
const area = target.dataset.homeArea as HomeArea
const targetIndex = Number(target.dataset.homeIndex)
if (
!appStore.extractHomeFolderApp(folder.id, sourceIndex, area, targetIndex)
) {
closeFolder()
return
}
closeFolder()
if (sourceBounds) {
const extractedIndex = findHomeItemIndex(appId, area, targetIndex)
if (extractedIndex !== null) {
@@ -1270,6 +1194,7 @@ watch(isEditablePage, (visible) => {
if (!visible) editMode.value = false
})
watch(editMode, (editing) => {
emit('editModeChange', editing)
if (editing) return
stopHomeDrag()
stopWidgetDrag()
@@ -1278,6 +1203,7 @@ watch(openedFolder, (folder) => {
if (!folder) closeFolder()
})
onBeforeUnmount(() => {
emit('editModeChange', false)
clearBackgroundHold()
clearEdgePageTurn()
clearFolderHover()
@@ -1286,7 +1212,6 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1301,7 +1226,8 @@ onBeforeUnmount(() => {
{
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': openedFolder !== null,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1377,7 +1303,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{
'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1385,7 +1310,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1400,7 +1325,6 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder"
:class="{
'home-item--folder-hover':
@@ -1409,7 +1333,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1560,11 +1484,9 @@ onBeforeUnmount(() => {
</section>
</div>
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !openedFolder"
v-if="editMode && isEditablePage && !folderOverlayVisible"
component="button"
class="springboard-edit-add"
type="button"
@@ -1577,13 +1499,14 @@ onBeforeUnmount(() => {
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !openedFolder"
v-if="editMode && isEditablePage && !folderOverlayVisible"
component="button"
class="springboard-edit-done"
type="button"
:aria-label="phone.t('Common.done')"
@click="editMode = false"
>
{{ phone.t('Common.done') }}
<Check :size="20" :stroke-width="3" aria-hidden="true" />
</k-glass>
</Transition>
@@ -1607,7 +1530,6 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false"
:class="{
'home-item--folder-hover':
@@ -1616,7 +1538,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1630,7 +1552,6 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder"
:show-label="false"
:class="{
@@ -1640,7 +1561,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@dragstart="startHomeDrag('dock', slot.index)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1702,6 +1623,7 @@ onBeforeUnmount(() => {
:folder="openedFolder"
:rename-on-open="renameFolderOnOpenId === openedFolder.id"
@close="closeFolder"
@drag-outside-change="folderDraggingOutside = $event"
@edit="enterEditMode"
@extract="extractOpenedFolderApp"
@move="moveOpenedFolderApp"
@@ -1710,50 +1632,56 @@ onBeforeUnmount(() => {
/>
</Transition>
<div class="widget-action-sheet">
<k-sheet
<SkyProvider
class="widget-action-provider"
:dark="phone.isDarkMode"
safe-areas
>
<SkyActionSheet
class="widget-action-sheet"
:opened="widgetActionId !== null"
:aria-label="
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
"
@backdropclick="widgetActionId = null"
@escape="widgetActionId = null"
>
<div class="widget-sheet-handle" />
<h3>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</h3>
<k-list inset strong class="widget-action-list">
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
<SkyActionGroup>
<SkyActionsLabel>
{{
activeWidget
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
: phone.t('Home.widgets.label')
}}
</SkyActionsLabel>
<SkyActionButton
class="widget-action-button"
@click="openWidgetConfig"
>
<template #media><Pencil :size="20" /></template>
</k-list-item>
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
<Pencil :size="20" />
<span>{{ phone.t('Home.widgetSystem.editWidget') }}</span>
</SkyActionButton>
<SkyActionButton
class="widget-action-button widget-action-remove"
@click="activeWidget && removeWidget(activeWidget.id)"
>
<template #media><Trash2 :size="20" /></template>
</k-list-item>
</k-list>
<k-button
large
rounded
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</k-button>
</k-sheet>
</div>
<Trash2 :size="20" />
<span>{{ phone.t('Home.widgetSystem.removeWidget') }}</span>
</SkyActionButton>
</SkyActionGroup>
<SkyActionGroup>
<SkyActionButton
bold
class="widget-action-cancel"
@click="widgetActionId = null"
>
{{ phone.t('Common.cancel') }}
</SkyActionButton>
</SkyActionGroup>
</SkyActionSheet>
</SkyProvider>
<WidgetPickerSheet
:opened="widgetPickerOpened"
@@ -1770,44 +1698,54 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.widget-action-sheet {
display: contents;
}
.widget-action-sheet :deep(.k-sheet) {
.widget-action-provider {
position: absolute;
z-index: 105;
padding: 9px 0 30px;
border-radius: 25px 25px 0 0;
inset: 0;
pointer-events: none;
}
.widget-sheet-handle {
width: 36px;
height: 5px;
margin: 0 auto 9px;
border-radius: 999px;
background: rgb(142 142 147 / 45%);
.widget-action-sheet {
--sky-overlay-layer: 105;
}
.widget-action-sheet h3 {
margin: 3px 18px 9px;
color: inherit;
font-size: 16px;
font-weight: 650;
text-align: center;
.widget-action-sheet :deep(.sky-overlay-backdrop) {
background: rgb(0 0 0 / 58%);
}
.widget-action-list {
margin-top: 0;
margin-bottom: 10px;
.widget-action-sheet :deep(.sky-action-sheet__panel) {
max-width: 100%;
}
.widget-action-remove :deep([class*='title']),
.widget-action-remove :deep(svg) {
color: #ff3b30 !important;
.widget-action-sheet :deep(.sky-action-group) {
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.widget-action-cancel {
width: calc(100% - 32px);
margin: 0 16px;
.widget-action-sheet :deep(.sky-actions-label) {
color: var(--sky-muted);
background: transparent;
font-size: 14px;
font-weight: 600;
}
.widget-action-sheet :deep(.sky-action-button) {
color: #fff;
font-size: 17px;
}
.widget-action-sheet :deep(.widget-action-button) {
justify-content: flex-start;
gap: var(--sky-space-3);
text-align: left;
}
.widget-action-sheet :deep(.widget-action-button svg) {
width: 24px;
flex: none;
}
.widget-action-sheet :deep(.widget-action-remove) {
color: var(--sky-danger);
}
</style>
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./SpringboardView.vue', import.meta.url),
'utf8',
)
describe('Springboard widget actions Sky UI contract', () => {
it('uses the first-party action sheet without legacy Konsta sheet markup', () => {
expect(source).not.toContain('<k-sheet')
expect(source).not.toContain('<k-list')
expect(source).not.toContain('<k-list-item')
expect(source).toContain('<SkyProvider')
expect(source).toContain('<SkyActionSheet')
expect(source).toContain('<SkyActionGroup')
expect(source).toContain('<SkyActionsLabel')
expect(source).toContain('<SkyActionButton')
})
it('anchors the action sheet to the phone-owned springboard', () => {
expect(source).toMatch(
/\.widget-action-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
)
expect(source).toMatch(
/\.widget-action-sheet\s*\{[^}]*--sky-overlay-layer:\s*105;/s,
)
})
it('keeps ordinary actions white and removal destructive', () => {
expect(source).toMatch(
/\.widget-action-sheet\s+:deep\(\.sky-action-button\)\s*\{[^}]*color:\s*#fff;/s,
)
expect(source).toMatch(
/\.widget-action-sheet\s+:deep\(\.widget-action-remove\)\s*\{[^}]*color:\s*var\(--sky-danger\);/s,
)
})
})