Files
Native-Router-Vue/packages/core/src/components/NativeSheet.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>