563 lines
16 KiB
Vue
563 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
useAttrs,
|
|
watch,
|
|
type PropType,
|
|
} from "vue";
|
|
import { useNativeRouter } from "./lifecycle";
|
|
import {
|
|
adjacentSheetBreakpoint,
|
|
nearestSheetBreakpoint,
|
|
normalizeSheetBreakpoints,
|
|
} from "./sheet";
|
|
|
|
defineOptions({ name: "NativeSheet", inheritAttrs: false });
|
|
|
|
const props = defineProps({
|
|
/** Fractions of the available, safe-area-contained route height. */
|
|
breakpoints: {
|
|
type: Array as PropType<number[]>,
|
|
default: () => [],
|
|
},
|
|
/** Initial fraction. The nearest declared breakpoint is used. */
|
|
initialBreakpoint: Number,
|
|
/** Current fraction for v-model. */
|
|
modelValue: Number,
|
|
dismissible: { type: Boolean, default: true },
|
|
backdropDismiss: { type: Boolean, default: true },
|
|
showHandle: { type: Boolean, default: true },
|
|
ariaLabel: { type: String, default: "Sheet" },
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: number];
|
|
"breakpoint-change": [value: number];
|
|
dismiss: [];
|
|
}>();
|
|
|
|
const attrs = useAttrs();
|
|
const runtime = useNativeRouter();
|
|
const root = ref<HTMLElement | null>(null);
|
|
const surface = ref<HTMLElement | null>(null);
|
|
const handle = ref<HTMLElement | null>(null);
|
|
const body = ref<HTMLElement | null>(null);
|
|
const content = ref<HTMLElement | null>(null);
|
|
const height = ref<number>();
|
|
const activeBreakpoint = ref<number>();
|
|
const dragging = ref(false);
|
|
const ready = ref(false);
|
|
const dismissing = ref(false);
|
|
const normalizedBreakpoints = computed(() =>
|
|
normalizeSheetBreakpoints(props.breakpoints),
|
|
);
|
|
const usesBreakpoints = computed(() => normalizedBreakpoints.value.length > 0);
|
|
const heightStyle = computed(() =>
|
|
height.value === undefined ? undefined : `${height.value}px`,
|
|
);
|
|
const breakpointLabel = computed(() =>
|
|
activeBreakpoint.value === undefined
|
|
? "content"
|
|
: String(activeBreakpoint.value),
|
|
);
|
|
|
|
let resizeObserver: ResizeObserver | undefined;
|
|
let owningLayer: HTMLElement | null = null;
|
|
let pointerId = -1;
|
|
let touchId = -1;
|
|
let candidateX = 0;
|
|
let candidateY = 0;
|
|
let candidateTime = 0;
|
|
let startY = 0;
|
|
let startHeight = 0;
|
|
let lastY = 0;
|
|
let lastTime = 0;
|
|
let velocity = 0;
|
|
let moved = false;
|
|
let wheelDistance = 0;
|
|
let wheelDirection: "up" | "down" | undefined;
|
|
let wheelOwner: "content" | "sheet" | undefined;
|
|
let wheelLocked = false;
|
|
let wheelResetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let contentGestureOwner: "pending" | "content" | "sheet" | undefined;
|
|
|
|
function availableHeight() {
|
|
return Math.max(1, root.value?.clientHeight ?? window.innerHeight);
|
|
}
|
|
|
|
function bodyPadding() {
|
|
if (!body.value) return 0;
|
|
const style = getComputedStyle(body.value);
|
|
return (
|
|
(Number.parseFloat(style.paddingTop) || 0) +
|
|
(Number.parseFloat(style.paddingBottom) || 0)
|
|
);
|
|
}
|
|
|
|
function naturalHeight() {
|
|
return Math.min(
|
|
availableHeight(),
|
|
Math.max(
|
|
1,
|
|
(handle.value?.offsetHeight ?? 0) +
|
|
(content.value?.scrollHeight ?? 0) +
|
|
bodyPadding(),
|
|
),
|
|
);
|
|
}
|
|
|
|
function requestedBreakpoint() {
|
|
const points = normalizedBreakpoints.value;
|
|
if (!points.length) return undefined;
|
|
return nearestSheetBreakpoint(
|
|
points,
|
|
props.modelValue ??
|
|
activeBreakpoint.value ??
|
|
props.initialBreakpoint ??
|
|
points[0]!,
|
|
);
|
|
}
|
|
|
|
function measure() {
|
|
const breakpoint = requestedBreakpoint();
|
|
activeBreakpoint.value = breakpoint;
|
|
height.value = breakpoint ? availableHeight() * breakpoint : naturalHeight();
|
|
}
|
|
|
|
function setBreakpoint(breakpoint: number, notify = true) {
|
|
const nearest = nearestSheetBreakpoint(
|
|
normalizedBreakpoints.value,
|
|
breakpoint,
|
|
);
|
|
if (nearest === undefined) return measure();
|
|
activeBreakpoint.value = nearest;
|
|
height.value = availableHeight() * nearest;
|
|
if (notify) {
|
|
emit("update:modelValue", nearest);
|
|
emit("breakpoint-change", nearest);
|
|
}
|
|
}
|
|
|
|
async function dismissSheet() {
|
|
if (!props.dismissible || dismissing.value) return false;
|
|
dismissing.value = true;
|
|
emit("dismiss");
|
|
const dismissed = await runtime.dismiss();
|
|
dismissing.value = false;
|
|
if (!dismissed) measure();
|
|
return dismissed;
|
|
}
|
|
|
|
function beginDrag(clientY: number, timestamp: number) {
|
|
startY = lastY = clientY;
|
|
lastTime = timestamp;
|
|
startHeight = height.value ?? surface.value?.offsetHeight ?? naturalHeight();
|
|
velocity = 0;
|
|
moved = false;
|
|
dragging.value = true;
|
|
}
|
|
|
|
function updateDrag(clientY: number, timestamp: number) {
|
|
const delta = clientY - startY;
|
|
moved ||= Math.abs(delta) > 3;
|
|
const minimum = usesBreakpoints.value
|
|
? availableHeight() * (normalizedBreakpoints.value[0] ?? 0.1) * 0.55
|
|
: naturalHeight() * 0.55;
|
|
height.value = Math.max(
|
|
Math.min(72, availableHeight()),
|
|
Math.min(availableHeight(), Math.max(minimum, startHeight - delta)),
|
|
);
|
|
const elapsed = Math.max(8, timestamp - lastTime);
|
|
velocity = ((clientY - lastY) * 1000) / elapsed / availableHeight();
|
|
lastY = clientY;
|
|
lastTime = timestamp;
|
|
}
|
|
|
|
async function finishDrag() {
|
|
dragging.value = false;
|
|
if (!moved) return measure();
|
|
|
|
const currentHeight = height.value ?? startHeight;
|
|
const currentFraction = currentHeight / availableHeight();
|
|
const points = normalizedBreakpoints.value;
|
|
const smallest = points[0];
|
|
|
|
if (!points.length) {
|
|
if (
|
|
props.dismissible &&
|
|
(currentHeight < naturalHeight() * 0.72 || velocity > 1.1)
|
|
)
|
|
return void (await dismissSheet());
|
|
return measure();
|
|
}
|
|
|
|
if (
|
|
props.dismissible &&
|
|
smallest !== undefined &&
|
|
currentFraction < smallest * 0.72
|
|
)
|
|
return void (await dismissSheet());
|
|
|
|
const active = activeBreakpoint.value ?? smallest!;
|
|
if (Math.abs(velocity) > 0.65) {
|
|
const direction = velocity < 0 ? "up" : "down";
|
|
const adjacent = adjacentSheetBreakpoint(points, active, direction);
|
|
if (adjacent !== undefined) return setBreakpoint(adjacent);
|
|
if (direction === "down" && props.dismissible)
|
|
return void (await dismissSheet());
|
|
}
|
|
|
|
setBreakpoint(nearestSheetBreakpoint(points, currentFraction) ?? active);
|
|
}
|
|
|
|
function cancelDrag() {
|
|
dragging.value = false;
|
|
measure();
|
|
}
|
|
|
|
function pointerDown(event: PointerEvent) {
|
|
if (!event.isPrimary || event.button !== 0 || dismissing.value) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
contentGestureOwner = "sheet";
|
|
pointerId = event.pointerId;
|
|
beginDrag(event.clientY, event.timeStamp);
|
|
handle.value?.setPointerCapture?.(pointerId);
|
|
}
|
|
|
|
function pointerMove(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
updateDrag(event.clientY, event.timeStamp);
|
|
}
|
|
|
|
async function pointerUp(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
handle.value?.releasePointerCapture?.(pointerId);
|
|
pointerId = -1;
|
|
await finishDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function pointerCancel(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
pointerId = -1;
|
|
cancelDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function atTop() {
|
|
return (body.value?.scrollTop ?? 0) <= 1;
|
|
}
|
|
|
|
function atBottom() {
|
|
const element = body.value;
|
|
if (!element) return true;
|
|
return element.scrollTop + element.clientHeight >= element.scrollHeight - 1;
|
|
}
|
|
|
|
function canResizeFromContent(direction: "up" | "down") {
|
|
const points = normalizedBreakpoints.value;
|
|
const active = activeBreakpoint.value;
|
|
if (direction === "up")
|
|
return Boolean(
|
|
atBottom() &&
|
|
active !== undefined &&
|
|
adjacentSheetBreakpoint(points, active, "up") !== undefined,
|
|
);
|
|
return Boolean(
|
|
atTop() &&
|
|
(props.dismissible ||
|
|
(active !== undefined &&
|
|
adjacentSheetBreakpoint(points, active, "down") !== undefined)),
|
|
);
|
|
}
|
|
|
|
function contentGestureDirection(deltaY: number) {
|
|
return deltaY < 0 ? ("up" as const) : ("down" as const);
|
|
}
|
|
|
|
function shouldClaimContentGesture(deltaX: number, deltaY: number) {
|
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
|
return false;
|
|
return canResizeFromContent(contentGestureDirection(deltaY));
|
|
}
|
|
|
|
function contentPointerDown(event: PointerEvent) {
|
|
if (
|
|
event.pointerType === "touch" ||
|
|
!event.isPrimary ||
|
|
event.button !== 0 ||
|
|
dismissing.value
|
|
)
|
|
return;
|
|
pointerId = event.pointerId;
|
|
contentGestureOwner = "pending";
|
|
candidateX = event.clientX;
|
|
candidateY = event.clientY;
|
|
candidateTime = event.timeStamp;
|
|
}
|
|
|
|
function contentPointerMove(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
if (contentGestureOwner === "pending") {
|
|
const deltaX = event.clientX - candidateX;
|
|
const deltaY = event.clientY - candidateY;
|
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
|
return;
|
|
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
|
? "sheet"
|
|
: "content";
|
|
if (contentGestureOwner === "content") return;
|
|
beginDrag(candidateY, candidateTime);
|
|
body.value?.setPointerCapture?.(pointerId);
|
|
}
|
|
if (contentGestureOwner !== "sheet") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
updateDrag(event.clientY, event.timeStamp);
|
|
}
|
|
|
|
async function contentPointerUp(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
body.value?.releasePointerCapture?.(pointerId);
|
|
pointerId = -1;
|
|
if (dragging.value) await finishDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function contentPointerCancel(event: PointerEvent) {
|
|
if (event.pointerId !== pointerId) return;
|
|
pointerId = -1;
|
|
if (dragging.value) cancelDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function trackedTouch(list: TouchList) {
|
|
return [...list].find((touch) => touch.identifier === touchId);
|
|
}
|
|
|
|
function contentTouchStart(event: TouchEvent) {
|
|
if (touchId !== -1 || dismissing.value) return;
|
|
const touch = event.changedTouches[0];
|
|
if (!touch) return;
|
|
touchId = touch.identifier;
|
|
contentGestureOwner = "pending";
|
|
candidateX = touch.clientX;
|
|
candidateY = touch.clientY;
|
|
candidateTime = event.timeStamp;
|
|
}
|
|
|
|
function contentTouchMove(event: TouchEvent) {
|
|
const touch = trackedTouch(event.touches);
|
|
if (!touch) return;
|
|
if (contentGestureOwner === "pending") {
|
|
const deltaX = touch.clientX - candidateX;
|
|
const deltaY = touch.clientY - candidateY;
|
|
if (Math.abs(deltaY) < 8 || Math.abs(deltaY) < Math.abs(deltaX) * 1.15)
|
|
return;
|
|
contentGestureOwner = shouldClaimContentGesture(deltaX, deltaY)
|
|
? "sheet"
|
|
: "content";
|
|
if (contentGestureOwner === "content") return;
|
|
beginDrag(candidateY, candidateTime);
|
|
}
|
|
if (contentGestureOwner !== "sheet") return;
|
|
if (event.cancelable) event.preventDefault();
|
|
event.stopPropagation();
|
|
updateDrag(touch.clientY, event.timeStamp);
|
|
}
|
|
|
|
async function contentTouchEnd(event: TouchEvent) {
|
|
if (!trackedTouch(event.changedTouches)) return;
|
|
touchId = -1;
|
|
if (dragging.value) await finishDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function contentTouchCancel(event: TouchEvent) {
|
|
if (!trackedTouch(event.changedTouches)) return;
|
|
touchId = -1;
|
|
if (dragging.value) cancelDrag();
|
|
contentGestureOwner = undefined;
|
|
}
|
|
|
|
function resetWheelHandoff() {
|
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
|
wheelDistance = 0;
|
|
wheelDirection = undefined;
|
|
wheelOwner = undefined;
|
|
wheelLocked = false;
|
|
wheelResetTimer = undefined;
|
|
}
|
|
|
|
function contentWheel(event: WheelEvent) {
|
|
const direction = event.deltaY > 0 ? ("up" as const) : ("down" as const);
|
|
if (!event.deltaY) return;
|
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
|
wheelResetTimer = setTimeout(resetWheelHandoff, 240);
|
|
|
|
if (!wheelOwner)
|
|
wheelOwner = canResizeFromContent(direction) ? "sheet" : "content";
|
|
if (wheelOwner === "content") return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (wheelDirection !== direction) wheelDistance = 0;
|
|
wheelDirection = direction;
|
|
wheelDistance += Math.abs(event.deltaY);
|
|
if (!wheelLocked && wheelDistance >= 48) {
|
|
const active = activeBreakpoint.value;
|
|
const adjacent =
|
|
active === undefined
|
|
? undefined
|
|
: adjacentSheetBreakpoint(
|
|
normalizedBreakpoints.value,
|
|
active,
|
|
direction,
|
|
);
|
|
if (adjacent !== undefined) setBreakpoint(adjacent);
|
|
else if (direction === "down") void dismissSheet();
|
|
wheelLocked = true;
|
|
}
|
|
}
|
|
|
|
function keyDown(event: KeyboardEvent) {
|
|
const points = normalizedBreakpoints.value;
|
|
const current = activeBreakpoint.value;
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
void dismissSheet();
|
|
return;
|
|
}
|
|
if (!points.length || current === undefined) return;
|
|
const direction =
|
|
event.key === "ArrowUp"
|
|
? "up"
|
|
: event.key === "ArrowDown"
|
|
? "down"
|
|
: undefined;
|
|
const target =
|
|
event.key === "Home"
|
|
? points[0]
|
|
: event.key === "End"
|
|
? points.at(-1)
|
|
: direction
|
|
? adjacentSheetBreakpoint(points, current, direction)
|
|
: undefined;
|
|
if (target !== undefined) {
|
|
event.preventDefault();
|
|
setBreakpoint(target);
|
|
} else if (direction === "down" && props.dismissible) {
|
|
event.preventDefault();
|
|
void dismissSheet();
|
|
}
|
|
}
|
|
|
|
function surfaceKeyDown(event: KeyboardEvent) {
|
|
if (event.key !== "Escape" || event.target === handle.value) return;
|
|
event.preventDefault();
|
|
void dismissSheet();
|
|
}
|
|
|
|
watch(
|
|
() => [props.modelValue, props.initialBreakpoint, props.breakpoints] as const,
|
|
() => {
|
|
if (!dragging.value) void nextTick(measure);
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
onMounted(() => {
|
|
owningLayer = root.value?.closest<HTMLElement>(".nvr-view") ?? null;
|
|
if (owningLayer) owningLayer.dataset.nativeSheetSurface = "";
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (!dragging.value) measure();
|
|
});
|
|
if (root.value) resizeObserver.observe(root.value);
|
|
if (content.value) resizeObserver.observe(content.value);
|
|
}
|
|
void nextTick(() => {
|
|
measure();
|
|
requestAnimationFrame(() => (ready.value = true));
|
|
});
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
resizeObserver?.disconnect();
|
|
if (wheelResetTimer) clearTimeout(wheelResetTimer);
|
|
if (owningLayer) delete owningLayer.dataset.nativeSheetSurface;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" v-bind="attrs" class="nvr-sheet">
|
|
<button
|
|
v-if="backdropDismiss && dismissible"
|
|
class="nvr-sheet__backdrop"
|
|
type="button"
|
|
aria-label="Close sheet"
|
|
@click="dismissSheet"
|
|
/>
|
|
<div v-else class="nvr-sheet__backdrop" aria-hidden="true" />
|
|
<section
|
|
ref="surface"
|
|
class="nvr-sheet__surface"
|
|
:style="{ height: heightStyle }"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-label="ariaLabel"
|
|
data-native-sheet
|
|
:data-native-sheet-mode="usesBreakpoints ? 'breakpoints' : 'content'"
|
|
:data-native-sheet-breakpoint="breakpointLabel"
|
|
:data-native-sheet-dragging="String(dragging)"
|
|
:data-native-sheet-ready="String(ready)"
|
|
@keydown="surfaceKeyDown"
|
|
>
|
|
<div
|
|
v-if="showHandle"
|
|
ref="handle"
|
|
class="nvr-sheet__handle"
|
|
:role="usesBreakpoints ? 'slider' : undefined"
|
|
:tabindex="0"
|
|
aria-label="Resize or dismiss sheet"
|
|
aria-orientation="vertical"
|
|
:aria-valuemin="usesBreakpoints ? normalizedBreakpoints[0] : undefined"
|
|
:aria-valuemax="
|
|
usesBreakpoints ? normalizedBreakpoints.at(-1) : undefined
|
|
"
|
|
:aria-valuenow="usesBreakpoints ? activeBreakpoint : undefined"
|
|
@pointerdown="pointerDown"
|
|
@pointermove="pointerMove"
|
|
@pointerup="pointerUp"
|
|
@pointercancel="pointerCancel"
|
|
@keydown="keyDown"
|
|
>
|
|
<slot name="handle">
|
|
<span aria-hidden="true" />
|
|
</slot>
|
|
</div>
|
|
<div
|
|
ref="body"
|
|
class="nvr-sheet__body"
|
|
@pointerdown="contentPointerDown"
|
|
@pointermove="contentPointerMove"
|
|
@pointerup="contentPointerUp"
|
|
@pointercancel="contentPointerCancel"
|
|
@touchstart="contentTouchStart"
|
|
@touchmove="contentTouchMove"
|
|
@touchend="contentTouchEnd"
|
|
@touchcancel="contentTouchCancel"
|
|
@wheel="contentWheel"
|
|
>
|
|
<div ref="content" class="nvr-sheet__content"><slot /></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|