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>