mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - rework homescreen editing and widgets
This commit is contained in:
+200
-187
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
@@ -27,13 +30,11 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -60,15 +61,16 @@ const calendarToday = ref(new Date())
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -173,9 +175,36 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
if (dragPageMetrics) {
|
||||
const pointer = springboardViewportToLocal(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
dragPageMetrics.viewportLeft,
|
||||
dragPageMetrics.viewportTop,
|
||||
dragPageMetrics.viewportWidth,
|
||||
dragPageMetrics.viewportHeight,
|
||||
dragPageMetrics.layoutWidth,
|
||||
dragPageMetrics.layoutHeight,
|
||||
)
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
dragPageMetrics.viewportLeft,
|
||||
dragPageMetrics.viewportTop,
|
||||
dragPageMetrics.viewportWidth,
|
||||
dragPageMetrics.viewportHeight,
|
||||
dragPageMetrics.layoutWidth,
|
||||
dragPageMetrics.layoutHeight,
|
||||
)
|
||||
dragOffset.value = {
|
||||
x: pointer.x - start.x,
|
||||
y: pointer.y - start.y,
|
||||
}
|
||||
} else {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
@@ -193,10 +222,15 @@ function onPointerMove(event: PointerEvent): void {
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
pointerTarget ??
|
||||
(event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null)
|
||||
dragPageMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragPageMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
@@ -235,6 +269,7 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragPageMetrics = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
@@ -265,7 +300,6 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
@@ -285,7 +319,6 @@ onBeforeUnmount(() => {
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
>
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
@@ -340,9 +373,9 @@ onBeforeUnmount(() => {
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<k-badge class="app-icon-remove__badge" tone="neutral">
|
||||
<Minus :size="15" :stroke-width="3" aria-hidden="true" />
|
||||
</k-badge>
|
||||
<span class="app-icon-remove__badge" aria-hidden="true">
|
||||
<Minus :size="14" :stroke-width="4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { bindPointerDragSession } from '@/utils/pointerDragSession'
|
||||
|
||||
@@ -19,13 +22,11 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -45,6 +46,7 @@ const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragMetrics: SpringboardDragMetrics | null = null
|
||||
let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
@@ -53,14 +55,14 @@ let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value && !props.externalDragVisual
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -84,18 +86,58 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragMetrics = null
|
||||
}
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
pointerTarget ??
|
||||
(event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null)
|
||||
dragMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function updateDragOffset(event: PointerEvent): void {
|
||||
if (dragMetrics) {
|
||||
const pointer = springboardViewportToLocal(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
dragMetrics.viewportLeft,
|
||||
dragMetrics.viewportTop,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
dragMetrics.viewportLeft,
|
||||
dragMetrics.viewportTop,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
dragOffset.value = {
|
||||
x: pointer.x - start.x,
|
||||
y: pointer.y - start.y,
|
||||
}
|
||||
return
|
||||
}
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return
|
||||
suppressClick.value = false
|
||||
@@ -110,10 +152,7 @@ function onPointerDown(event: PointerEvent): void {
|
||||
})
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
return
|
||||
}
|
||||
if (props.editMode) return
|
||||
holdTimer = window.setTimeout(() => {
|
||||
suppressClick.value = true
|
||||
emit('edit')
|
||||
@@ -124,10 +163,7 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
updateDragOffset(event)
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
@@ -139,6 +175,11 @@ function onPointerMove(event: PointerEvent): void {
|
||||
) {
|
||||
suppressClick.value = true
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginPointerDrag(event)
|
||||
updateDragOffset(event)
|
||||
emit('dragmove', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +206,7 @@ function cancelPointerDrag(): void {
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
if (props.editMode || suppressClick.value) {
|
||||
if (suppressClick.value) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
@@ -191,7 +232,6 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
@@ -210,7 +250,6 @@ onBeforeUnmount(() => {
|
||||
@contextmenu.prevent
|
||||
@keydown="onKeydown"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
>
|
||||
<span class="home-folder-preview" aria-hidden="true">
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const iconSource = readFileSync(
|
||||
new URL('./HomeFolderIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const overlaySource = readFileSync(
|
||||
new URL('./HomeFolderOverlay.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const dragSource = readFileSync(
|
||||
new URL('../utils/springboardDrag.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const springboardSource = readFileSync(
|
||||
new URL('../views/SpringboardView.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const appIconSource = readFileSync(
|
||||
new URL('./AppIcon.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('Home folder interaction contract', () => {
|
||||
it('opens folders from edit mode and starts dragging only after movement', () => {
|
||||
expect(iconSource).toContain('if (props.editMode) return')
|
||||
expect(iconSource).toMatch(
|
||||
/if \(props\.editMode\) \{\s+beginPointerDrag\(event\)/,
|
||||
)
|
||||
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
|
||||
})
|
||||
|
||||
it('keeps folder app dragging aligned at every phone scale', () => {
|
||||
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
|
||||
expect(dragSource).toContain('viewportWidth: bounds.width')
|
||||
expect(dragSource).not.toContain("getPropertyValue('zoom')")
|
||||
expect(iconSource).toContain('springboardViewportToLocal')
|
||||
expect(iconSource).not.toContain('@pointerleave')
|
||||
expect(springboardSource).not.toContain(
|
||||
'(event.currentTarget as HTMLElement | null)?.closest',
|
||||
)
|
||||
})
|
||||
|
||||
it('edits the folder name inline with Sky UI controls', () => {
|
||||
expect(overlaySource).toContain('<SkyField')
|
||||
expect(overlaySource).toContain('home-folder-heading--editing')
|
||||
expect(overlaySource).toContain('<Check')
|
||||
expect(overlaySource).toContain('<X')
|
||||
expect(overlaySource).not.toContain('<SkyDialog')
|
||||
expect(overlaySource).not.toContain('<SkySheet')
|
||||
})
|
||||
|
||||
it('visually closes the folder while an app leaves and closes after extraction', () => {
|
||||
expect(overlaySource).toContain('@dragmove="moveFolderAppDrag"')
|
||||
expect(overlaySource).toContain("emit('drag-outside-change', outside)")
|
||||
expect(overlaySource).toContain('home-folder-layer--dragging-out')
|
||||
expect(springboardSource).toContain(
|
||||
'@drag-outside-change="folderDraggingOutside = $event"',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'if (draggingIndex.value === null || draggingOutside.value) return',
|
||||
)
|
||||
expect(overlaySource).toContain(
|
||||
'draggingOutside.value || !isPointerInsidePanel(event)',
|
||||
)
|
||||
expect(springboardSource).toContain(
|
||||
"'springboard--folder-open': folderOverlayVisible",
|
||||
)
|
||||
expect(springboardSource).toContain(
|
||||
'editMode && isEditablePage && !folderOverlayVisible',
|
||||
)
|
||||
expect(springboardSource).toMatch(
|
||||
/extractHomeFolderApp\([\s\S]*?closeFolder\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the folder title left-aligned and gives the rename field more height', () => {
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-heading\s*\{[^}]*justify-content:\s*flex-start;/s,
|
||||
)
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-title\s*\{[^}]*text-align:\s*left;/s,
|
||||
)
|
||||
expect(overlaySource).toMatch(
|
||||
/\.home-folder-rename-field :deep\(\.sky-field__input\)\s*\{[^}]*height:\s*46px;[^}]*min-height:\s*46px;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one native iOS-style removal badge inside and outside folders', () => {
|
||||
expect(appIconSource).toContain('<span class="app-icon-remove__badge"')
|
||||
expect(appIconSource).not.toContain(
|
||||
'<k-badge class="app-icon-remove__badge"',
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-remove__badge\s*\{[^}]*background:\s*rgb\(174 174 178 \/ 88%\);[^}]*color:\s*#111;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil } from 'lucide-vue-next'
|
||||
import { Check, Pencil, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyField,
|
||||
SkyLink,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
import { SkyField, SkyLink } from '@/ui'
|
||||
import {
|
||||
HOME_FOLDER_NAME_MAX_LENGTH,
|
||||
HOME_FOLDER_PAGE_SIZE,
|
||||
@@ -30,6 +25,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'drag-outside-change': [outside: boolean]
|
||||
edit: []
|
||||
extract: [index: number, event: PointerEvent]
|
||||
move: [sourceIndex: number, targetIndex: number]
|
||||
@@ -41,6 +37,8 @@ const phone = usePhoneStore()
|
||||
const currentPage = ref(0)
|
||||
const pageTransitionDirection = ref<'backward' | 'forward'>('forward')
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const draggingOutside = ref(false)
|
||||
const panelElement = ref<HTMLElement | null>(null)
|
||||
const renameOpened = ref(false)
|
||||
const renameDraft = ref('')
|
||||
let pagePointerStart = 0
|
||||
@@ -87,7 +85,9 @@ watch(
|
||||
emit('rename-opened')
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
.querySelector<HTMLInputElement>(
|
||||
'.home-folder-rename-field .sky-field__input',
|
||||
)
|
||||
?.select()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -98,36 +98,62 @@ function openRename(): void {
|
||||
renameOpened.value = true
|
||||
void nextTick(() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
|
||||
.querySelector<HTMLInputElement>(
|
||||
'.home-folder-rename-field .sky-field__input',
|
||||
)
|
||||
?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function saveRename(): void {
|
||||
emit('rename', renameDraft.value.trim() || phone.t('Home.folders.defaultName'))
|
||||
emit(
|
||||
'rename',
|
||||
renameDraft.value.trim() || phone.t('Home.folders.defaultName'),
|
||||
)
|
||||
renameOpened.value = false
|
||||
}
|
||||
|
||||
function startFolderAppDrag(index: number): void {
|
||||
draggingIndex.value = index
|
||||
setDraggingOutside(false)
|
||||
}
|
||||
|
||||
function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const sourceIndex = draggingIndex.value
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) return
|
||||
const panel = document.querySelector<HTMLElement>('.home-folder-panel')
|
||||
if (!panel) return
|
||||
const panelBounds = panel.getBoundingClientRect()
|
||||
const insidePanel =
|
||||
function isPointerInsidePanel(event: PointerEvent): boolean {
|
||||
const panelBounds = panelElement.value?.getBoundingClientRect()
|
||||
if (!panelBounds) return false
|
||||
return (
|
||||
event.clientX >= panelBounds.left &&
|
||||
event.clientX <= panelBounds.right &&
|
||||
event.clientY >= panelBounds.top &&
|
||||
event.clientY <= panelBounds.bottom
|
||||
if (!insidePanel) {
|
||||
emit('extract', sourceIndex, event)
|
||||
)
|
||||
}
|
||||
|
||||
function moveFolderAppDrag(event: PointerEvent): void {
|
||||
if (draggingIndex.value === null || draggingOutside.value) return
|
||||
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
|
||||
}
|
||||
|
||||
function setDraggingOutside(outside: boolean): void {
|
||||
if (draggingOutside.value === outside) return
|
||||
draggingOutside.value = outside
|
||||
emit('drag-outside-change', outside)
|
||||
}
|
||||
|
||||
function finishFolderAppDrag(event: PointerEvent): void {
|
||||
const sourceIndex = draggingIndex.value
|
||||
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
|
||||
draggingIndex.value = null
|
||||
if (sourceIndex === null) {
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
if (shouldExtract) {
|
||||
emit('extract', sourceIndex, event)
|
||||
setDraggingOutside(false)
|
||||
return
|
||||
}
|
||||
setDraggingOutside(false)
|
||||
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
@@ -143,6 +169,7 @@ function finishFolderAppDrag(event: PointerEvent): void {
|
||||
|
||||
function stopFolderAppDrag(): void {
|
||||
draggingIndex.value = null
|
||||
setDraggingOutside(false)
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
@@ -171,7 +198,10 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-folder-layer">
|
||||
<div
|
||||
class="home-folder-layer"
|
||||
:class="{ 'home-folder-layer--dragging-out': draggingOutside }"
|
||||
>
|
||||
<button
|
||||
class="home-folder-backdrop"
|
||||
type="button"
|
||||
@@ -185,7 +215,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
:aria-label="folderName"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="home-folder-heading">
|
||||
<div v-if="!renameOpened" class="home-folder-heading">
|
||||
<button
|
||||
class="home-folder-title"
|
||||
type="button"
|
||||
@@ -205,7 +235,46 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
<Pencil :size="18" :stroke-width="2.2" />
|
||||
</SkyLink>
|
||||
</div>
|
||||
<form
|
||||
v-else
|
||||
class="home-folder-heading home-folder-heading--editing"
|
||||
@keydown.esc.prevent="renameOpened = false"
|
||||
@submit.prevent="saveRename"
|
||||
>
|
||||
<SkyLink
|
||||
class="home-folder-rename-action"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Common.cancel')"
|
||||
type="button"
|
||||
@click="renameOpened = false"
|
||||
>
|
||||
<X :size="19" :stroke-width="2.3" />
|
||||
</SkyLink>
|
||||
<SkyField
|
||||
v-model="renameDraft"
|
||||
class="home-folder-rename-field"
|
||||
:aria-label="phone.t('Home.folders.name')"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
clear-button
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
component="div"
|
||||
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
|
||||
:placeholder="phone.t('Home.folders.defaultName')"
|
||||
/>
|
||||
<SkyLink
|
||||
class="home-folder-rename-action"
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Common.save')"
|
||||
type="submit"
|
||||
>
|
||||
<Check :size="20" :stroke-width="2.5" />
|
||||
</SkyLink>
|
||||
</form>
|
||||
<div
|
||||
ref="panelElement"
|
||||
class="home-folder-panel"
|
||||
:class="{ 'home-folder-panel--dragging': draggingIndex !== null }"
|
||||
@pointerdown="startPageSwipe"
|
||||
@@ -223,6 +292,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopFolderAppDrag"
|
||||
@dragend="finishFolderAppDrag"
|
||||
@dragmove="moveFolderAppDrag"
|
||||
@dragstart="startFolderAppDrag(entry.index)"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
@@ -245,51 +315,6 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SkySheet
|
||||
class="home-folder-rename"
|
||||
:opened="renameOpened"
|
||||
:aria-label="phone.t('Home.folders.rename')"
|
||||
@backdropclick="renameOpened = false"
|
||||
@escape="renameOpened = false"
|
||||
>
|
||||
<form class="home-folder-rename__content" @submit.prevent="saveRename">
|
||||
<span class="home-folder-rename__handle" aria-hidden="true"></span>
|
||||
<header>
|
||||
<div>
|
||||
<small>{{ phone.t('Home.folders.name') }}</small>
|
||||
<h2>{{ phone.t('Home.folders.rename') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="home-folder-rename__fields">
|
||||
<SkyField
|
||||
v-model="renameDraft"
|
||||
:aria-label="phone.t('Home.folders.name')"
|
||||
autocomplete="off"
|
||||
clear-button
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
|
||||
outline
|
||||
:placeholder="phone.t('Home.folders.defaultName')"
|
||||
/>
|
||||
</ul>
|
||||
<div class="home-folder-rename__actions">
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="renameOpened = false"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</SkyButton>
|
||||
<SkyButton block large rounded type="submit">
|
||||
{{ phone.t('Common.save') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
</form>
|
||||
</SkySheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -322,30 +347,32 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
|
||||
.home-folder-dialog {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
top: 118px;
|
||||
right: 30px;
|
||||
left: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.home-folder-heading {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.home-folder-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 49px);
|
||||
min-height: 44px;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 2px 12px;
|
||||
padding: 2px 8px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
@@ -356,7 +383,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 8px rgb(0 0 0 / 38%);
|
||||
white-space: nowrap;
|
||||
@@ -388,11 +415,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
.home-folder-panel {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 342px;
|
||||
padding: 25px 22px 16px;
|
||||
min-height: 324px;
|
||||
padding: 22px 16px 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
border-radius: 28px;
|
||||
border-radius: 26px;
|
||||
background: rgb(115 135 176 / 72%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 18%),
|
||||
@@ -406,8 +433,8 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
|
||||
.home-folder-page-viewport {
|
||||
position: relative;
|
||||
min-height: 285px;
|
||||
margin-top: -8px;
|
||||
min-height: 270px;
|
||||
margin-top: -6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -418,11 +445,11 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
.home-folder-page-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 8px;
|
||||
top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, 79px);
|
||||
gap: 13px 20px;
|
||||
gap: 10px 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -465,6 +492,36 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.home-folder-backdrop,
|
||||
.home-folder-heading,
|
||||
.home-folder-panel,
|
||||
.home-folder-page-grid :deep(.app-icon-item),
|
||||
.home-folder-pages {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-backdrop {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-heading,
|
||||
.home-folder-layer--dragging-out .home-folder-pages,
|
||||
.home-folder-layer--dragging-out
|
||||
.home-folder-page-grid
|
||||
:deep(.app-icon-item:not(.app-icon-item--dragging)) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.home-folder-layer--dragging-out .home-folder-panel {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.home-folder-pages {
|
||||
min-height: 20px;
|
||||
margin-top: 5px;
|
||||
@@ -491,59 +548,57 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.home-folder-rename {
|
||||
--sky-overlay-layer: 90;
|
||||
.home-folder-heading--editing {
|
||||
min-height: 52px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-folder-rename :deep(.sky-sheet__panel) {
|
||||
.home-folder-rename-field {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 26px 26px 0 0;
|
||||
background: rgb(28 28 30 / 98%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content {
|
||||
padding: 9px 16px calc(20px + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.home-folder-rename__handle {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.home-folder-rename__content header {
|
||||
margin: 0 2px 14px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content header small {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--sky-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.home-folder-rename__content h2 {
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 21px;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
.home-folder-rename__fields {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
border: 1px solid rgb(255 255 255 / 26%);
|
||||
border-radius: 14px;
|
||||
background: rgb(28 28 30 / 78%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.home-folder-rename__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
.home-folder-rename-field :deep(.sky-field__inner) {
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__input) {
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__input::placeholder) {
|
||||
color: rgb(255 255 255 / 48%);
|
||||
}
|
||||
|
||||
.home-folder-rename-field :deep(.sky-field__clear) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.home-folder-rename-action {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 13%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.home-folder-rename-action:active {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -32,7 +32,9 @@ const frameImage = computed(
|
||||
)
|
||||
const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
...getHairlinePixelStyle(props.zoom, props.devicePixelRatio),
|
||||
zoom: props.zoom,
|
||||
'--phone-rendered-height': `${844 * props.zoom}px`,
|
||||
'--phone-rendered-width': `${390 * props.zoom}px`,
|
||||
'--phone-zoom': props.zoom,
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -41,52 +43,54 @@ const wrapperStyle = computed<CSSProperties>(() => ({
|
||||
class="phone-resolution-wrapper phone-resolution-wrapper--notification"
|
||||
:style="wrapperStyle"
|
||||
>
|
||||
<section
|
||||
class="phone-device phone-device--notification"
|
||||
:class="{
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<div
|
||||
class="phone-screen"
|
||||
<div class="phone-resolution-canvas">
|
||||
<section
|
||||
class="phone-device phone-device--notification"
|
||||
:class="{
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="isDarkMode"
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
<div
|
||||
class="phone-screen"
|
||||
:class="{
|
||||
dark: isDarkMode,
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
>
|
||||
<PhoneLockScreen
|
||||
:notifications="[]"
|
||||
:preferences="preferences"
|
||||
preview
|
||||
/>
|
||||
<PhoneNotifications
|
||||
<k-app
|
||||
theme="ios"
|
||||
:dark="isDarkMode"
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
@open="emit('open', $event)"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="frameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
safe-areas
|
||||
class="phone-app"
|
||||
:class="{
|
||||
dark: isDarkMode,
|
||||
'phone-app--light': !isDarkMode,
|
||||
[`phone-app--${preferences.settings.graphicsMode}`]: true,
|
||||
}"
|
||||
>
|
||||
<PhoneLockScreen
|
||||
:notifications="[]"
|
||||
:preferences="preferences"
|
||||
preview
|
||||
/>
|
||||
<PhoneNotifications
|
||||
:dark="isDarkMode"
|
||||
:notification="notification"
|
||||
@close="emit('close')"
|
||||
@open="emit('open', $event)"
|
||||
/>
|
||||
</k-app>
|
||||
</div>
|
||||
<img
|
||||
class="phone-device__frame"
|
||||
:src="frameImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,8 +31,11 @@ import {
|
||||
type ReorderDirection,
|
||||
} from '@/utils/keyboard'
|
||||
import {
|
||||
readSpringboardDragMetrics,
|
||||
springboardPageDragCompensation,
|
||||
springboardSwipeIntent,
|
||||
springboardViewportDeltaToLocal,
|
||||
type SpringboardDragMetrics,
|
||||
} from '@/utils/springboardDrag'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
@@ -78,6 +81,7 @@ let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragMetrics: SpringboardDragMetrics | null = null
|
||||
let pointerTarget: HTMLElement | null = null
|
||||
let pointerId: number | null = null
|
||||
|
||||
@@ -207,10 +211,18 @@ function onPointerDown(event: PointerEvent): void {
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
const deltaX = event.clientX - pointerStart.x
|
||||
const deltaY = event.clientY - pointerStart.y
|
||||
dragOffset.value = dragMetrics
|
||||
? springboardViewportDeltaToLocal(
|
||||
deltaX,
|
||||
deltaY,
|
||||
dragMetrics.viewportWidth,
|
||||
dragMetrics.viewportHeight,
|
||||
dragMetrics.layoutWidth,
|
||||
dragMetrics.layoutHeight,
|
||||
)
|
||||
: { x: deltaX, y: deltaY }
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
@@ -227,10 +239,14 @@ function onPointerMove(event: PointerEvent): void {
|
||||
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.currentTarget as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
const element =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
dragMetrics = readSpringboardDragMetrics(element)
|
||||
dragPageWidth = dragMetrics?.layoutWidth ?? 0
|
||||
isDragging.value = true
|
||||
emit('dragstart', event)
|
||||
}
|
||||
@@ -265,6 +281,7 @@ function releasePointerCapture(): void {
|
||||
}
|
||||
pointerTarget = null
|
||||
pointerId = null
|
||||
dragMetrics = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const configSource = readFileSync(
|
||||
new URL('./WidgetConfigSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const pickerSource = readFileSync(
|
||||
new URL('./WidgetPickerSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const widgetSource = readFileSync(
|
||||
new URL('./SpringboardWidget.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WidgetConfigSheet Sky UI contract', () => {
|
||||
it('uses first-party Sky UI controls for the configuration surface', () => {
|
||||
expect(configSource).not.toContain("from 'konsta/vue'")
|
||||
expect(configSource).not.toMatch(/<\/?k-[a-z]/)
|
||||
expect(configSource).toContain('<SkyProvider')
|
||||
expect(configSource).toContain('<SkySheet')
|
||||
expect(configSource).toContain('<SkyScrollArea')
|
||||
expect(configSource).toContain('<SkySegmented')
|
||||
expect(configSource).toContain('<SkySettingsGroup')
|
||||
expect(configSource).toContain('<SkySettingsRow')
|
||||
})
|
||||
|
||||
it('keeps the sheet inside the phone-owned springboard geometry', () => {
|
||||
expect(configSource).not.toContain('<Teleport')
|
||||
expect(configSource).toMatch(
|
||||
/\.widget-config-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(configSource).toMatch(
|
||||
/\.widget-config-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('has one scroll owner and preserves all configurable values', () => {
|
||||
expect(configSource.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
expect(configSource).toContain('balanceSource: balanceSource.value')
|
||||
expect(configSource).toContain('contactIds: contactIds.value')
|
||||
expect(configSource).toContain('showDate: showDate.value')
|
||||
expect(configSource).toContain('supportedSizes')
|
||||
})
|
||||
|
||||
it('lets the widget component own preview geometry for every size', () => {
|
||||
expect(configSource).not.toContain(':deep(.home-widget-shell--preview)')
|
||||
expect(pickerSource).not.toContain(':deep(.home-widget-shell--preview)')
|
||||
expect(widgetSource).toMatch(
|
||||
/\.home-widget-shell--preview\.home-widget-shell--small\s*\{[^}]*max-width:\s*150px;[^}]*aspect-ratio:\s*1;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import {
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
@@ -22,6 +10,16 @@ import type {
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyProvider,
|
||||
SkyScrollArea,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkySettingsGroup,
|
||||
SkySettingsRow,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: WidgetInstance | null
|
||||
@@ -39,9 +37,15 @@ const size = ref<WidgetSize>('small')
|
||||
const showDate = ref(true)
|
||||
const balanceSource = ref<'bank' | 'cash'>('bank')
|
||||
const contactIds = ref<string[]>([])
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
const supportedSizes = computed(
|
||||
() =>
|
||||
(props.instance &&
|
||||
WIDGET_REGISTRY_BY_KIND.get(props.instance.kind)?.supportedSizes) ||
|
||||
[],
|
||||
)
|
||||
const activeSizeIndex = computed(() =>
|
||||
Math.max(0, supportedSizes.value.indexOf(size.value)),
|
||||
)
|
||||
|
||||
function toggleContact(id: string): void {
|
||||
const index = contactIds.value.indexOf(id)
|
||||
@@ -71,191 +75,260 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-config-sheet">
|
||||
<k-sheet
|
||||
<SkyProvider
|
||||
class="widget-config-provider"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<SkySheet
|
||||
class="widget-config-sheet"
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
:aria-label="phone.t('Home.widgetSystem.configure')"
|
||||
grabber-clickable
|
||||
:grabber-label="phone.t('Common.cancel')"
|
||||
swipe-to-close
|
||||
@backdropclick="emit('close')"
|
||||
@escape="emit('close')"
|
||||
@grabberclick="emit('close')"
|
||||
@swipeclose="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
v-if="instance"
|
||||
class="widget-config-page"
|
||||
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
|
||||
<template #left>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
<section v-if="instance" class="widget-config-surface">
|
||||
<header class="widget-config-navbar">
|
||||
<SkyButton
|
||||
class="widget-config-nav-button"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="save">
|
||||
</SkyButton>
|
||||
<h2>{{ phone.t('Home.widgetSystem.configure') }}</h2>
|
||||
<SkyButton
|
||||
class="widget-config-nav-button"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="save"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
</SkyButton>
|
||||
</header>
|
||||
|
||||
<div class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
<SkyScrollArea class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="widget-config-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
?.supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
<section class="widget-config-size">
|
||||
<h3>{{ phone.t('Home.widgetSystem.size') }}</h3>
|
||||
<SkySegmented
|
||||
:active-index="activeSizeIndex"
|
||||
:aria-label="phone.t('Home.widgetSystem.size')"
|
||||
:item-count="supportedSizes.length"
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
<SkySegmentedButton
|
||||
v-for="supportedSize in supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</section>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'clock'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
|
||||
<template #after>
|
||||
<k-toggle :checked="showDate" @change="showDate = !showDate" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'clock'"
|
||||
class="widget-config-group"
|
||||
:aria-label="phone.t('Home.widgetSystem.clock.name')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
v-model="showDate"
|
||||
kind="toggle"
|
||||
:title="phone.t('Home.widgetSystem.clock.showDate')"
|
||||
/>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'wallet'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
|
||||
<template #after>
|
||||
<k-segmented class="widget-config-balance">
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'wallet'"
|
||||
class="widget-config-group"
|
||||
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
kind="custom"
|
||||
:title="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
>
|
||||
<template #trailing>
|
||||
<SkySegmented
|
||||
:active-index="balanceSource === 'bank' ? 0 : 1"
|
||||
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
|
||||
class="widget-config-balance"
|
||||
:item-count="2"
|
||||
rounded
|
||||
strong
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<SkySegmentedButton
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
</template>
|
||||
</SkySettingsRow>
|
||||
</SkySettingsGroup>
|
||||
|
||||
<section v-if="instance.kind === 'contacts'">
|
||||
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
|
||||
<k-list inset strong class="widget-config-list">
|
||||
<k-list-item
|
||||
<SkySettingsGroup
|
||||
v-if="instance.kind === 'contacts'"
|
||||
class="widget-config-group"
|
||||
:title="phone.t('Home.widgetSystem.contacts.choose')"
|
||||
>
|
||||
<SkySettingsRow
|
||||
v-for="contact in contactsService.contacts.value"
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
kind="choice"
|
||||
:description="contact.phone_number"
|
||||
:selected="contactIds.includes(contact.id)"
|
||||
:title="contact.name"
|
||||
:subtitle="contact.phone_number"
|
||||
@click="toggleContact(contact.id)"
|
||||
@activate="toggleContact(contact.id)"
|
||||
>
|
||||
<template #media>
|
||||
<template #leading>
|
||||
<span class="widget-config-avatar">{{
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-if="contactIds.includes(contact.id)" #after>
|
||||
<Check :size="20" class="widget-config-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</div>
|
||||
</SkySettingsRow>
|
||||
</SkySettingsGroup>
|
||||
</SkyScrollArea>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</SkyProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-config-sheet :deep(.k-sheet) {
|
||||
.widget-config-provider {
|
||||
position: absolute;
|
||||
z-index: 115;
|
||||
height: calc(100% - 22px);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-config-sheet {
|
||||
--sky-overlay-layer: 115;
|
||||
}
|
||||
|
||||
.widget-config-sheet :deep(.sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 58%);
|
||||
}
|
||||
|
||||
.widget-config-sheet :deep(.sky-sheet__panel) {
|
||||
height: calc(100% - var(--sky-space-3));
|
||||
max-height: calc(100% - var(--sky-space-3));
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
background: var(--sky-bg);
|
||||
}
|
||||
|
||||
.widget-config-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
.widget-config-surface {
|
||||
height: calc(100% - 32px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-config-page--dark {
|
||||
.widget-config-navbar {
|
||||
min-height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sky-space-2);
|
||||
padding: 0 var(--sky-space-3);
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
}
|
||||
|
||||
.widget-config-navbar h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: var(--sky-font-title);
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-config-navbar > :first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.widget-config-navbar > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.widget-config-navbar :deep(.widget-config-nav-button.sky-button) {
|
||||
min-height: var(--sky-touch-target);
|
||||
padding: 0 var(--sky-space-4);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
background: var(--sky-surface-variant);
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-config-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 12px 42px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-config-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
padding: var(--sky-space-3) var(--sky-page-gutter)
|
||||
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.widget-config-preview {
|
||||
display: flex;
|
||||
min-height: 215px;
|
||||
padding: 20px 12px;
|
||||
min-height: 250px;
|
||||
padding: var(--sky-space-4) 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-config-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-config-size {
|
||||
display: grid;
|
||||
margin: 0 4px 16px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-config-size > span,
|
||||
.widget-config-scroll h3 {
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
.widget-config-size h3 {
|
||||
margin: 0 0 var(--sky-space-2) var(--sky-space-3);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-config-scroll h3 {
|
||||
margin: 18px 14px 7px;
|
||||
.widget-config-size :deep(.sky-segmented) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widget-config-list :deep([class*='title']) {
|
||||
color: inherit;
|
||||
.widget-config-group {
|
||||
margin: 0 0 var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-config-balance {
|
||||
width: 138px;
|
||||
width: 146px;
|
||||
}
|
||||
|
||||
.widget-config-avatar {
|
||||
@@ -269,7 +342,9 @@ watch(
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-config-check {
|
||||
color: #0a84ff;
|
||||
@media (max-height: 700px) {
|
||||
.widget-config-preview {
|
||||
min-height: 205px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./WidgetPickerSheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('WidgetPickerSheet Sky UI contract', () => {
|
||||
it('uses only first-party Sky UI primitives for the picker surface', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).not.toMatch(/<\/?k-[a-z]/)
|
||||
expect(source).toContain('<SkyProvider')
|
||||
expect(source).toContain('<SkySheet')
|
||||
expect(source).toContain('<SkySearchbar')
|
||||
expect(source).toContain('<SkyList')
|
||||
expect(source).toContain('<SkyListItem')
|
||||
expect(source).toContain('<SkyEmptyState')
|
||||
expect(source).toContain('<SkyButton')
|
||||
})
|
||||
|
||||
it('keeps the modal inside the phone-owned springboard geometry', () => {
|
||||
expect(source).not.toContain('<Teleport')
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the iOS gallery, preview, size, and add sequence', () => {
|
||||
expect(source).toContain("ref<'gallery' | 'preview'>('gallery')")
|
||||
expect(source).toContain("pickerView.value = 'preview'")
|
||||
expect(source).toContain("pickerView.value = 'gallery'")
|
||||
expect(source).toContain('selectedDefinition.supportedSizes')
|
||||
expect(source).toContain('startPreviewSwipe')
|
||||
expect(source).toContain('widget-picker-size-dots')
|
||||
expect(source).toContain("phone.t('Home.widgetSystem.addWidget')")
|
||||
})
|
||||
|
||||
it('has one explicit vertical scroll owner', () => {
|
||||
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
expect(source).toMatch(
|
||||
/\.widget-picker-scroll\s*\{[^}]*overflow-y:\s*auto;/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Clock3,
|
||||
CloudSun,
|
||||
Music,
|
||||
@@ -9,18 +9,6 @@ import {
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
@@ -32,6 +20,16 @@ import type {
|
||||
WidgetKind,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
import {
|
||||
SkyButton,
|
||||
SkyEmptyState,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyProvider,
|
||||
SkyScrollArea,
|
||||
SkySearchbar,
|
||||
SkySheet,
|
||||
} from '@/ui'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -40,8 +38,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const query = ref('')
|
||||
const pickerView = ref<'gallery' | 'preview'>('gallery')
|
||||
const selectedKind = ref<WidgetKind>('clock')
|
||||
const selectedSize = ref<WidgetSize>('small')
|
||||
let previewPointerStartX: number | null = null
|
||||
|
||||
const icons: Record<WidgetKind, Component> = {
|
||||
clock: Clock3,
|
||||
@@ -90,214 +90,447 @@ const previewInstance = computed<WidgetInstance>(() => ({
|
||||
},
|
||||
size: selectedSize.value,
|
||||
}))
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
const selectedSizeIndex = computed(() =>
|
||||
selectedDefinition.value.supportedSizes.indexOf(selectedSize.value),
|
||||
)
|
||||
|
||||
function selectWidget(definition: WidgetDefinition): void {
|
||||
selectedKind.value = definition.kind
|
||||
if (!definition.supportedSizes.includes(selectedSize.value))
|
||||
selectedSize.value = definition.defaultSize
|
||||
selectedSize.value = definition.defaultSize
|
||||
pickerView.value = 'preview'
|
||||
}
|
||||
|
||||
function addWidget(): void {
|
||||
emit('add', selectedKind.value, selectedSize.value)
|
||||
}
|
||||
|
||||
function closePicker(): void {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function showGallery(): void {
|
||||
pickerView.value = 'gallery'
|
||||
}
|
||||
|
||||
function changeSize(offset: number): void {
|
||||
const sizes = selectedDefinition.value.supportedSizes
|
||||
const currentIndex = Math.max(0, sizes.indexOf(selectedSize.value))
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(sizes.length - 1, currentIndex + offset),
|
||||
)
|
||||
selectedSize.value = sizes[nextIndex] ?? selectedSize.value
|
||||
}
|
||||
|
||||
function startPreviewSwipe(event: PointerEvent): void {
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
(event.pointerType === 'mouse' && event.button !== 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
previewPointerStartX = event.clientX
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function finishPreviewSwipe(event: PointerEvent): void {
|
||||
if (previewPointerStartX === null) return
|
||||
|
||||
const deltaX = event.clientX - previewPointerStartX
|
||||
previewPointerStartX = null
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (target.hasPointerCapture(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
if (Math.abs(deltaX) < 36) return
|
||||
changeSize(deltaX < 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
function cancelPreviewSwipe(): void {
|
||||
previewPointerStartX = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(opened) => {
|
||||
if (!opened) query.value = ''
|
||||
if (opened) return
|
||||
query.value = ''
|
||||
pickerView.value = 'gallery'
|
||||
previewPointerStartX = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-picker-sheet">
|
||||
<k-sheet
|
||||
<SkyProvider
|
||||
class="widget-picker-provider"
|
||||
:dark="phone.isDarkMode"
|
||||
safe-areas
|
||||
>
|
||||
<SkySheet
|
||||
class="widget-picker-sheet"
|
||||
:opened="opened"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
:aria-label="phone.t('Home.widgetSystem.galleryTitle')"
|
||||
grabber-clickable
|
||||
:grabber-label="phone.t('Common.close')"
|
||||
swipe-to-close
|
||||
@backdropclick="closePicker"
|
||||
@escape="closePicker"
|
||||
@grabberclick="closePicker"
|
||||
@swipeclose="closePicker"
|
||||
>
|
||||
<k-page
|
||||
class="widget-picker-page"
|
||||
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
<section class="widget-picker-surface">
|
||||
<header class="widget-picker-navbar">
|
||||
<SkyButton
|
||||
v-if="pickerView === 'preview'"
|
||||
class="widget-picker-nav-button widget-picker-nav-button--icon"
|
||||
icon-only
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="showGallery"
|
||||
>
|
||||
<ChevronLeft :size="21" :stroke-width="2.4" />
|
||||
</SkyButton>
|
||||
<span v-else aria-hidden="true"></span>
|
||||
|
||||
<h2>
|
||||
{{
|
||||
pickerView === 'gallery'
|
||||
? phone.t('Home.widgetSystem.galleryTitle')
|
||||
: phone.t(selectedDefinition.labelKey)
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<SkyButton
|
||||
class="widget-picker-nav-button widget-picker-done"
|
||||
inline
|
||||
rounded
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="closePicker"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
</SkyButton>
|
||||
</header>
|
||||
|
||||
<div class="widget-picker-scroll">
|
||||
<k-searchbar
|
||||
:value="query"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
@input="query = inputValue($event)"
|
||||
@clear="query = ''"
|
||||
/>
|
||||
<SkyScrollArea class="widget-picker-scroll">
|
||||
<template v-if="pickerView === 'gallery'">
|
||||
<SkySearchbar
|
||||
v-model="query"
|
||||
class="widget-picker-search"
|
||||
:clear-label="phone.t('Common.clear')"
|
||||
:label="phone.t('Home.widgetSystem.search')"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
/>
|
||||
|
||||
<section class="widget-picker-preview">
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="widget-picker-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="size in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
:active="selectedSize === size"
|
||||
@click="selectedSize = size"
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<SkyList flush strong class="widget-picker-list">
|
||||
<SkyListItem
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
chevron
|
||||
link
|
||||
link-component="button"
|
||||
:text="phone.t(definition.descriptionKey)"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
@click="selectWidget(definition)"
|
||||
>
|
||||
<template #media>
|
||||
<span
|
||||
class="widget-picker-icon"
|
||||
:class="`widget-picker-icon--${definition.kind}`"
|
||||
>
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
</SkyListItem>
|
||||
</SkyList>
|
||||
</section>
|
||||
|
||||
<k-button large rounded class="widget-picker-add" @click="addWidget">
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</k-button>
|
||||
<SkyEmptyState
|
||||
v-if="filteredWidgets.length === 0"
|
||||
compact
|
||||
class="widget-picker-empty"
|
||||
:title="phone.t('Home.widgetSystem.noResults')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
:subtitle="phone.t(definition.descriptionKey)"
|
||||
@click="selectWidget(definition)"
|
||||
<section v-else class="widget-picker-preview">
|
||||
<div
|
||||
class="widget-picker-preview-stage"
|
||||
role="group"
|
||||
tabindex="0"
|
||||
:aria-label="`${phone.t('Home.widgetSystem.size')}: ${phone.t(`Home.widgetSystem.sizes.${selectedSize}`)}`"
|
||||
@keydown.left.prevent="changeSize(-1)"
|
||||
@keydown.right.prevent="changeSize(1)"
|
||||
@lostpointercapture="cancelPreviewSwipe"
|
||||
@pointercancel="cancelPreviewSwipe"
|
||||
@pointerdown="startPreviewSwipe"
|
||||
@pointerup="finishPreviewSwipe"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-picker-icon">
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="selectedKind === definition.kind" #after>
|
||||
<Check :size="20" class="widget-picker-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
|
||||
{{ phone.t('Home.widgetSystem.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</div>
|
||||
<div class="widget-picker-size-copy" aria-live="polite">
|
||||
<strong>{{
|
||||
phone.t(`Home.widgetSystem.sizes.${selectedSize}`)
|
||||
}}</strong>
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="widget-picker-size-dots"
|
||||
:aria-label="phone.t('Home.widgetSystem.size')"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
v-for="(size, index) in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
type="button"
|
||||
:class="{
|
||||
'widget-picker-size-dot--active': index === selectedSizeIndex,
|
||||
}"
|
||||
:aria-label="phone.t(`Home.widgetSystem.sizes.${size}`)"
|
||||
:aria-pressed="index === selectedSizeIndex"
|
||||
@click="selectedSize = size"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<h3>{{ phone.t(selectedDefinition.labelKey) }}</h3>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
rounded
|
||||
class="widget-picker-add"
|
||||
@click="addWidget"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</SkyButton>
|
||||
</section>
|
||||
</SkyScrollArea>
|
||||
</section>
|
||||
</SkySheet>
|
||||
</SkyProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-picker-sheet :deep(.k-sheet) {
|
||||
.widget-picker-provider {
|
||||
position: absolute;
|
||||
z-index: 110;
|
||||
height: calc(100% - 22px);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widget-picker-sheet {
|
||||
--sky-overlay-layer: 110;
|
||||
}
|
||||
|
||||
.widget-picker-sheet :deep(.sky-overlay-backdrop) {
|
||||
background: rgb(0 0 0 / 58%);
|
||||
}
|
||||
|
||||
.widget-picker-sheet :deep(.sky-sheet__panel) {
|
||||
height: calc(100% - var(--sky-space-3));
|
||||
max-height: calc(100% - var(--sky-space-3));
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
background: var(--sky-bg);
|
||||
}
|
||||
|
||||
.widget-picker-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
.widget-picker-surface {
|
||||
height: calc(100% - 32px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sky-bg);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-picker-page--dark {
|
||||
.widget-picker-navbar {
|
||||
min-height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sky-space-2);
|
||||
padding: 0 var(--sky-space-3);
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
}
|
||||
|
||||
.widget-picker-navbar h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: var(--sky-font-title);
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-picker-navbar > :first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.widget-picker-navbar > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.widget-picker-navbar :deep(.widget-picker-nav-button.sky-button) {
|
||||
min-height: var(--sky-touch-target);
|
||||
padding: 0 var(--sky-space-4);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
background: var(--sky-surface-variant);
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-navbar :deep(.widget-picker-nav-button--icon.sky-button) {
|
||||
width: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.widget-picker-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 13px 42px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding: var(--sky-space-3) var(--sky-page-gutter)
|
||||
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
|
||||
}
|
||||
|
||||
.widget-picker-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
.widget-picker-search {
|
||||
margin-bottom: var(--sky-space-5);
|
||||
}
|
||||
|
||||
.widget-picker-preview {
|
||||
display: flex;
|
||||
min-height: 235px;
|
||||
padding: 22px 14px 14px;
|
||||
min-height: 100%;
|
||||
padding: var(--sky-space-4) 0 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-picker-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-picker-preview h2 {
|
||||
margin: 14px 0 3px;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 285px;
|
||||
margin: 0;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark .widget-picker-preview p {
|
||||
color: #98989d;
|
||||
}
|
||||
|
||||
.widget-picker-size {
|
||||
display: grid;
|
||||
margin: 0 4px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
.widget-picker-preview-stage {
|
||||
width: 100%;
|
||||
min-height: 310px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
outline: 0;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-picker-size > span {
|
||||
font-size: 13px;
|
||||
.widget-picker-preview-stage:focus-visible {
|
||||
border-radius: var(--sky-radius-card);
|
||||
box-shadow: inset 0 0 0 2px var(--sky-app-accent);
|
||||
}
|
||||
|
||||
.widget-picker-size-copy {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--sky-space-2);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
}
|
||||
|
||||
.widget-picker-size-copy strong {
|
||||
color: var(--sky-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-size-dots {
|
||||
min-height: var(--sky-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sky-space-2);
|
||||
}
|
||||
|
||||
.widget-picker-size-dots button {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--sky-subtle);
|
||||
transition: background-color var(--sky-transition-fast) ease;
|
||||
}
|
||||
|
||||
.widget-picker-size-dots button::before {
|
||||
position: absolute;
|
||||
inset: -18px -8px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.widget-picker-size-dots .widget-picker-size-dot--active {
|
||||
background: var(--sky-text);
|
||||
}
|
||||
|
||||
.widget-picker-preview h3 {
|
||||
margin: 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.35px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 290px;
|
||||
margin: var(--sky-space-1) 0 var(--sky-space-5);
|
||||
color: var(--sky-muted);
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.widget-picker-add {
|
||||
margin-bottom: 22px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.widget-picker-category h3 {
|
||||
margin: 18px 12px 7px;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: var(--sky-space-5) var(--sky-space-3) var(--sky-space-2);
|
||||
color: var(--sky-muted);
|
||||
font-size: var(--sky-font-caption);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-picker-category :deep([class*='title']) {
|
||||
color: inherit;
|
||||
.widget-picker-category:first-of-type h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.widget-picker-list {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border-radius: var(--sky-radius-card);
|
||||
}
|
||||
|
||||
.widget-picker-list :deep(.sky-list-item__content) {
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.widget-picker-list :deep(.sky-list-item__text) {
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.widget-picker-icon {
|
||||
@@ -305,18 +538,48 @@ watch(
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: #2c2c2e;
|
||||
}
|
||||
|
||||
.widget-picker-check {
|
||||
color: #0a84ff;
|
||||
.widget-picker-icon--date {
|
||||
background: #ff453a;
|
||||
}
|
||||
|
||||
.widget-picker-icon--weather {
|
||||
background: #0a84ff;
|
||||
}
|
||||
|
||||
.widget-picker-icon--music {
|
||||
background: #ff375f;
|
||||
}
|
||||
|
||||
.widget-picker-icon--wallet {
|
||||
background: #5856d6;
|
||||
}
|
||||
|
||||
.widget-picker-icon--transactions {
|
||||
background: #30b35a;
|
||||
}
|
||||
|
||||
.widget-picker-icon--contacts {
|
||||
background: #ff9f0a;
|
||||
}
|
||||
|
||||
.widget-picker-empty {
|
||||
padding: 40px 20px;
|
||||
color: #8e8e93;
|
||||
text-align: center;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.widget-picker-preview-stage {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.widget-picker-size-dots button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user