mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +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>
|
||||
|
||||
Reference in New Issue
Block a user