diff --git a/frontend/src/ui/overlays.css b/frontend/src/ui/overlays.css index ae9b224..b3608c1 100644 --- a/frontend/src/ui/overlays.css +++ b/frontend/src/ui/overlays.css @@ -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; } } diff --git a/frontend/src/ui/overlays/SkySheet.test.ts b/frontend/src/ui/overlays/SkySheet.test.ts new file mode 100644 index 0000000..45f81c5 --- /dev/null +++ b/frontend/src/ui/overlays/SkySheet.test.ts @@ -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['$props'], +): Promise { + 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, + ) + }) +}) diff --git a/frontend/src/ui/overlays/SkySheet.vue b/frontend/src/ui/overlays/SkySheet.vue index b042b52..fb55ac6 100644 --- a/frontend/src/ui/overlays/SkySheet.vue +++ b/frontend/src/ui/overlays/SkySheet.vue @@ -1,5 +1,5 @@