Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode.
This commit is contained in:
562
packages/core/src/components/NativeSheet.vue
Normal file
562
packages/core/src/components/NativeSheet.vue
Normal file
@@ -0,0 +1,562 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user