mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
FIX - repair House sheets and swipe dismissal
Keep House overlay roots full-screen, size the actual panels, and remove duplicate headers and broken close controls. Add an opt-in SkySheet drag handle with pointer capture, velocity and distance thresholds, reduced-motion support, and focused contract coverage.
This commit is contained in:
@@ -199,11 +199,46 @@
|
||||
border: 0;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--sky-radius-sheet) var(--sky-radius-sheet) 0 0;
|
||||
background: var(--sky-surface);
|
||||
background: var(--sky-sheet-background, var(--sky-surface));
|
||||
color: var(--sky-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sky-sheet__panel {
|
||||
transform: translateY(var(--sky-sheet-drag-offset, 0));
|
||||
}
|
||||
|
||||
.sky-sheet__panel--dragging {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sky-sheet__panel--settling {
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sky-sheet__grabber {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: inherit;
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sky-sheet__grabber::after {
|
||||
width: 42px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
content: '';
|
||||
background: rgba(120, 120, 128, 0.38);
|
||||
}
|
||||
|
||||
.sky-action-sheet__panel {
|
||||
max-width: 448px;
|
||||
margin: 0 auto;
|
||||
@@ -1104,7 +1139,8 @@
|
||||
.sky-toast-slide-enter-active,
|
||||
.sky-toast-slide-leave-active,
|
||||
.sky-notification-slide-enter-active,
|
||||
.sky-notification-slide-leave-active {
|
||||
.sky-notification-slide-leave-active,
|
||||
.sky-sheet__panel--settling {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { createSSRApp, h } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import SkySheet from './SkySheet.vue'
|
||||
|
||||
async function renderSheet(
|
||||
props: InstanceType<typeof SkySheet>['$props'],
|
||||
): Promise<string> {
|
||||
return renderToString(
|
||||
createSSRApp({
|
||||
render: () => h(SkySheet, props, () => 'Sheet content'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkySheet', () => {
|
||||
it('renders an opt-in drag handle without changing ordinary sheets', async () => {
|
||||
const swipeable = await renderSheet({
|
||||
ariaLabel: 'Property details',
|
||||
opened: true,
|
||||
swipeToClose: true,
|
||||
})
|
||||
const ordinary = await renderSheet({ opened: true })
|
||||
|
||||
expect(swipeable).toContain('class="sky-sheet__grabber"')
|
||||
expect(swipeable).toContain('Sheet content')
|
||||
expect(ordinary).not.toContain('sky-sheet__grabber')
|
||||
})
|
||||
|
||||
it('owns pointer capture, close thresholds, and settling motion', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./SkySheet.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const overlays = readFileSync(
|
||||
new URL('../overlays.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(source).toContain('swipeclose: [event: PointerEvent]')
|
||||
expect(source).toContain('setPointerCapture(event.pointerId)')
|
||||
expect(source).toContain('dragOffset.value >= closeThreshold')
|
||||
expect(source).toContain("emit('swipeclose', event)")
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-sheet__grabber\s*\{[^}]*touch-action:\s*none;/s,
|
||||
)
|
||||
expect(overlays).toMatch(
|
||||
/\.sky-sheet__panel--settling\s*\{[^}]*transition:\s*transform 220ms/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, toRef, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, toRef, watch } from 'vue'
|
||||
import { useOverlayFocusTrap } from './useOverlayFocusTrap'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
@@ -14,6 +14,7 @@ const props = withDefaults(
|
||||
component?: string
|
||||
opened: boolean
|
||||
role?: 'alertdialog' | 'dialog' | 'none' | 'presentation'
|
||||
swipeToClose?: boolean
|
||||
tabindex?: number | string
|
||||
}>(),
|
||||
{
|
||||
@@ -26,11 +27,20 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
backdropclick: [event: MouseEvent]
|
||||
escape: [event: KeyboardEvent]
|
||||
swipeclose: [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const dragOffset = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const isSettling = ref(false)
|
||||
const inferredRole = ref<'alertdialog' | 'dialog' | 'none' | 'presentation'>()
|
||||
let activePointerId: number | null = null
|
||||
let dragStartedAt = 0
|
||||
let dragStartY = 0
|
||||
let settleTimer: number | undefined
|
||||
|
||||
const effectiveRole = computed(() => {
|
||||
const role = inferredRole.value
|
||||
const isDialog = role === 'dialog' || role === 'alertdialog'
|
||||
@@ -38,6 +48,86 @@ const effectiveRole = computed(() => {
|
||||
? 'presentation'
|
||||
: role
|
||||
})
|
||||
const panelStyle = computed(() =>
|
||||
props.swipeToClose && dragOffset.value > 0
|
||||
? { '--sky-sheet-drag-offset': `${dragOffset.value}px` }
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function clearSettleTimer(): void {
|
||||
if (!settleTimer) return
|
||||
window.clearTimeout(settleTimer)
|
||||
settleTimer = undefined
|
||||
}
|
||||
|
||||
function settleDrag(): void {
|
||||
clearSettleTimer()
|
||||
isSettling.value = true
|
||||
dragOffset.value = 0
|
||||
settleTimer = window.setTimeout(() => {
|
||||
isSettling.value = false
|
||||
settleTimer = undefined
|
||||
}, 220)
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent): void {
|
||||
if (
|
||||
!props.swipeToClose ||
|
||||
!event.isPrimary ||
|
||||
(event.pointerType === 'mouse' && event.button !== 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
clearSettleTimer()
|
||||
activePointerId = event.pointerId
|
||||
dragStartY = event.clientY
|
||||
dragStartedAt = performance.now()
|
||||
dragOffset.value = 0
|
||||
isDragging.value = true
|
||||
isSettling.value = false
|
||||
const handle = event.currentTarget as HTMLElement
|
||||
handle.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function moveDrag(event: PointerEvent): void {
|
||||
if (event.pointerId !== activePointerId) return
|
||||
dragOffset.value = Math.max(0, event.clientY - dragStartY)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function finishDrag(event: PointerEvent, cancelled = false): void {
|
||||
if (event.pointerId !== activePointerId) return
|
||||
|
||||
const handle = event.currentTarget as HTMLElement
|
||||
const pointerId = activePointerId
|
||||
const elapsed = Math.max(performance.now() - dragStartedAt, 1)
|
||||
const velocity = dragOffset.value / elapsed
|
||||
const closeThreshold = Math.min(
|
||||
Math.max((panel.value?.offsetHeight ?? 0) * 0.18, 72),
|
||||
110,
|
||||
)
|
||||
const shouldClose =
|
||||
!cancelled &&
|
||||
(dragOffset.value >= closeThreshold ||
|
||||
(dragOffset.value >= 28 && velocity >= 0.65))
|
||||
|
||||
activePointerId = null
|
||||
isDragging.value = false
|
||||
if (handle.hasPointerCapture(pointerId)) {
|
||||
handle.releasePointerCapture(pointerId)
|
||||
}
|
||||
|
||||
if (shouldClose) {
|
||||
emit('swipeclose', event)
|
||||
void nextTick(() => {
|
||||
if (props.opened) settleDrag()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settleDrag()
|
||||
}
|
||||
|
||||
watch(
|
||||
[toRef(props, 'opened'), toRef(props, 'role')],
|
||||
@@ -56,6 +146,17 @@ watch(
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(toRef(props, 'opened'), (opened) => {
|
||||
if (opened) return
|
||||
activePointerId = null
|
||||
dragOffset.value = 0
|
||||
isDragging.value = false
|
||||
isSettling.value = false
|
||||
clearSettleTimer()
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearSettleTimer)
|
||||
|
||||
useOverlayFocusTrap({
|
||||
onEscape: (event) => emit('escape', event),
|
||||
opened: toRef(props, 'opened'),
|
||||
@@ -81,6 +182,11 @@ useOverlayFocusTrap({
|
||||
:is="component"
|
||||
ref="panel"
|
||||
class="sky-sheet__panel"
|
||||
:class="{
|
||||
'sky-sheet__panel--dragging': isDragging,
|
||||
'sky-sheet__panel--settling': isSettling,
|
||||
}"
|
||||
:style="panelStyle"
|
||||
:role="effectiveRole"
|
||||
:aria-modal="
|
||||
effectiveRole === 'dialog' || effectiveRole === 'alertdialog'
|
||||
@@ -94,6 +200,16 @@ useOverlayFocusTrap({
|
||||
:aria-describedby="effectiveRole ? ariaDescribedby : undefined"
|
||||
:tabindex="tabindex"
|
||||
>
|
||||
<div
|
||||
v-if="swipeToClose"
|
||||
class="sky-sheet__grabber"
|
||||
aria-hidden="true"
|
||||
@lostpointercapture="finishDrag($event, true)"
|
||||
@pointercancel="finishDrag($event, true)"
|
||||
@pointerdown="startDrag"
|
||||
@pointermove="moveDrag"
|
||||
@pointerup="finishDrag($event)"
|
||||
></div>
|
||||
<slot />
|
||||
</component>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./HouseApp.vue', import.meta.url), 'utf8')
|
||||
|
||||
describe('House app sheets', () => {
|
||||
it('closes both sheets through their shared drag gesture', () => {
|
||||
expect(source.match(/swipe-to-close/g)).toHaveLength(2)
|
||||
expect(source).toContain('@swipeclose="selectedPropertyId = null"')
|
||||
expect(source).toContain('@swipeclose="candidatesOpened = false"')
|
||||
})
|
||||
|
||||
it('sizes the panels instead of clipping the overlay roots', () => {
|
||||
const rootRule = source.match(
|
||||
/:global\(\.house-detail-sheet\),\s*:global\(\.house-candidates-sheet\)\s*\{(?<declarations>[^}]*)\}/,
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(rootRule).toBeDefined()
|
||||
expect(rootRule).not.toContain('height:')
|
||||
expect(source).toMatch(
|
||||
/:global\(\.house-detail-sheet \.sky-sheet__panel\),[\s\S]*?height:\s*88%;/,
|
||||
)
|
||||
expect(source).not.toContain('height: 620px;')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SkyCard,
|
||||
SkyDialog,
|
||||
SkyDialogButton,
|
||||
SkyLink,
|
||||
SkyList,
|
||||
SkyListItem,
|
||||
SkyNavbar,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
UserRound,
|
||||
UsersRound,
|
||||
WifiOff,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -296,14 +294,6 @@ onBeforeUnmount(() => {
|
||||
<sky-spinner />
|
||||
</div>
|
||||
<section v-if="properties.length" class="house-properties">
|
||||
<header class="house-properties__heading">
|
||||
<div>
|
||||
<small>{{ phone.t('Apps.house.properties') }}</small>
|
||||
<h1>{{ phone.t('Apps.house.myHomes') }}</h1>
|
||||
</div>
|
||||
<span class="house-properties__count">{{ properties.length }}</span>
|
||||
</header>
|
||||
|
||||
<div class="house-overview" :aria-label="phone.t('Apps.house.myHomes')">
|
||||
<span
|
||||
><b>{{ ownedCount }}</b
|
||||
@@ -373,19 +363,12 @@ onBeforeUnmount(() => {
|
||||
<sky-sheet
|
||||
:opened="Boolean(selectedProperty)"
|
||||
class="house-detail-sheet"
|
||||
swipe-to-close
|
||||
@backdropclick="selectedPropertyId = null"
|
||||
@escape="selectedPropertyId = null"
|
||||
@swipeclose="selectedPropertyId = null"
|
||||
>
|
||||
<section v-if="selectedProperty" class="house-detail">
|
||||
<sky-glass
|
||||
component="button"
|
||||
class="house-detail__close"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.close')"
|
||||
@click="selectedPropertyId = null"
|
||||
>
|
||||
<X :size="18" />
|
||||
</sky-glass>
|
||||
|
||||
<span class="house-detail__mark"><House :size="29" /></span>
|
||||
<small>{{ accessLabel(selectedProperty) }}</small>
|
||||
<h2>{{ selectedProperty.name }}</h2>
|
||||
@@ -559,17 +542,12 @@ onBeforeUnmount(() => {
|
||||
<sky-sheet
|
||||
:opened="candidatesOpened"
|
||||
class="house-candidates-sheet"
|
||||
swipe-to-close
|
||||
@backdropclick="candidatesOpened = false"
|
||||
@escape="candidatesOpened = false"
|
||||
@swipeclose="candidatesOpened = false"
|
||||
>
|
||||
<section class="house-candidates">
|
||||
<sky-link
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Common.close')"
|
||||
:link-props="{ type: 'button' }"
|
||||
@click="candidatesOpened = false"
|
||||
><X :size="18"
|
||||
/></sky-link>
|
||||
<UsersRound :size="34" />
|
||||
<h2>{{ phone.t('Apps.house.chooseResident') }}</h2>
|
||||
<p>{{ phone.t('Apps.house.chooseResidentBody') }}</p>
|
||||
@@ -669,6 +647,17 @@ onBeforeUnmount(() => {
|
||||
.house-navbar::after {
|
||||
opacity: 0;
|
||||
}
|
||||
.house-navbar :deep(.sky-navbar__heading) {
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
}
|
||||
.house-navbar :deep(.sky-navbar__title) {
|
||||
line-height: 24px;
|
||||
}
|
||||
.house-navbar :deep(.sky-navbar__subtitle) {
|
||||
margin-top: 0;
|
||||
line-height: 15px;
|
||||
}
|
||||
.house-scroll {
|
||||
position: absolute;
|
||||
inset: 104px 0 25px;
|
||||
@@ -903,29 +892,21 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
:global(.house-detail-sheet),
|
||||
:global(.house-candidates-sheet) {
|
||||
--sky-sheet__panel-bg-color: var(--house-bg);
|
||||
--sky-sheet-background: var(--house-bg);
|
||||
}
|
||||
:global(.house-detail-sheet .sky-sheet__panel),
|
||||
:global(.house-candidates-sheet .sky-sheet__panel) {
|
||||
height: 88%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
.house-detail {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 18px 14px 40px;
|
||||
overflow-y: auto;
|
||||
min-height: calc(100% - 32px);
|
||||
padding: 12px 14px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.house-detail__close {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 0 -32px auto;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.house-detail__mark {
|
||||
width: 83px;
|
||||
height: 83px;
|
||||
@@ -1037,18 +1018,12 @@ onBeforeUnmount(() => {
|
||||
padding: 18px;
|
||||
}
|
||||
.house-candidates {
|
||||
height: 100%;
|
||||
padding: 19px 14px 35px;
|
||||
overflow-y: auto;
|
||||
min-height: calc(100% - 32px);
|
||||
padding: 12px 14px 35px;
|
||||
text-align: center;
|
||||
}
|
||||
.house-candidates > button {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 13px;
|
||||
}
|
||||
.house-candidates > svg {
|
||||
margin-top: 7px;
|
||||
margin-top: 0;
|
||||
color: var(--house-accent);
|
||||
}
|
||||
.house-candidates h2 {
|
||||
@@ -1112,42 +1087,10 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.house-properties {
|
||||
padding-top: 14px;
|
||||
}
|
||||
.house-properties__heading {
|
||||
margin: 0 21px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.house-properties__heading small,
|
||||
.house-properties__heading h1 {
|
||||
display: block;
|
||||
}
|
||||
.house-properties__heading small {
|
||||
color: var(--house-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.house-properties__heading h1 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 25px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.house-properties__count {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--house-accent);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.house-overview {
|
||||
margin: 0 16px 20px;
|
||||
margin: 0 16px 16px;
|
||||
padding: 12px 6px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
@@ -1241,13 +1184,13 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.42;
|
||||
}
|
||||
.house-detail {
|
||||
height: 620px;
|
||||
padding: 18px 0 42px;
|
||||
min-height: calc(100% - 32px);
|
||||
padding: 12px 0 42px;
|
||||
}
|
||||
.house-detail__mark {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 8px auto 10px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 18px;
|
||||
background: var(--house-accent);
|
||||
}
|
||||
@@ -1295,8 +1238,8 @@ onBeforeUnmount(() => {
|
||||
font-size: 16px;
|
||||
}
|
||||
.house-candidates {
|
||||
height: 620px;
|
||||
padding: 21px 14px 35px;
|
||||
min-height: calc(100% - 32px);
|
||||
padding: 12px 14px 35px;
|
||||
}
|
||||
.house-candidates h2 {
|
||||
margin: 8px 0 5px;
|
||||
|
||||
Reference in New Issue
Block a user