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:
Leon.Schmidt
2026-08-16 18:17:21 +02:00
parent fbb304c23c
commit a183d4978b
5 changed files with 270 additions and 95 deletions
+38 -2
View File
@@ -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;
}
}
+54
View File
@@ -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,
)
})
})
+117 -1
View File
@@ -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;')
})
})
+35 -92
View File
@@ -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;