Increase complexity of test demo
This commit is contained in:
@@ -153,6 +153,57 @@ export const fall = defineOriginChoreography({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected presentation rather than a full-screen route replacement.
|
||||
*
|
||||
* At rest the page has moved right by two thirds, leaving its left third
|
||||
* visible in the exposed rail. `persistAtRest` keeps these progress-1 effects
|
||||
* connected after commit instead of parking and hiding the page.
|
||||
*/
|
||||
export const partialDrawerOpen = defineOriginChoreography({
|
||||
name: "partial-drawer-open",
|
||||
persistAtRest: true,
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.68,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * (2 / 3))}, 0, 0)`,
|
||||
style: {
|
||||
filter: `brightness(${1 - progress * 0.22})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent((progress - 1) * (2 / 3))}, 0, 0)`,
|
||||
opacity: 0.35 + progress * 0.65,
|
||||
style: {
|
||||
boxShadow: `24px 0 70px rgb(0 0 0 / ${progress * 0.36})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reciprocal close motion for the connected two-thirds drawer. */
|
||||
export const partialDrawerClose = defineOriginChoreography({
|
||||
name: "partial-drawer-close",
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.68,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * (-2 / 3))}, 0, 0)`,
|
||||
opacity: 1 - progress * 0.65,
|
||||
style: {
|
||||
boxShadow: `24px 0 70px rgb(0 0 0 / ${(1 - progress) * 0.36})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent((1 - progress) * (2 / 3))}, 0, 0)`,
|
||||
style: {
|
||||
filter: `brightness(${0.78 + progress * 0.22})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveal a full-screen overlay from above while its actual dialog remains
|
||||
* anchored to the left. The gesture's custom start predicate lives in the
|
||||
|
||||
@@ -343,6 +343,14 @@ summary:focus-visible {
|
||||
--lab-glow: #438cff;
|
||||
}
|
||||
|
||||
.lab-card--teal {
|
||||
--lab-glow: #4fffd0;
|
||||
}
|
||||
|
||||
.lab-card--orange {
|
||||
--lab-glow: #ff7b42;
|
||||
}
|
||||
|
||||
.lab-number {
|
||||
color: rgb(255 255 255 / 42%);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
@@ -401,6 +409,444 @@ summary:focus-visible {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Replace checkout */
|
||||
|
||||
.checkout-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: max(1.25rem, env(safe-area-inset-top))
|
||||
max(1.25rem, env(safe-area-inset-right))
|
||||
max(1.25rem, env(safe-area-inset-bottom))
|
||||
max(1.25rem, env(safe-area-inset-left));
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 20%,
|
||||
rgb(34 101 100 / 34%),
|
||||
transparent 32rem
|
||||
),
|
||||
linear-gradient(145deg, #07100f, #0d1518 55%, #07100f);
|
||||
}
|
||||
|
||||
.checkout-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
width: min(100%, 68rem);
|
||||
margin: 0 auto;
|
||||
color: rgb(255 255 255 / 62%);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.checkout-toolbar button {
|
||||
justify-self: start;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.checkout-step {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.checkout-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(20rem, 27rem);
|
||||
align-items: center;
|
||||
gap: clamp(2rem, 7vw, 7rem);
|
||||
width: min(100%, 68rem);
|
||||
min-height: calc(100% - 3rem);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.checkout-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkout-copy h1,
|
||||
.confirmation-card h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(3.4rem, 8vw, 7rem);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.checkout-copy p,
|
||||
.confirmation-card p {
|
||||
max-width: 34rem;
|
||||
margin: 0 0 0.5rem;
|
||||
color: rgb(255 255 255 / 61%);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 1.8rem;
|
||||
padding: 1.4rem;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
box-shadow: 0 2rem 6rem rgb(0 0 0 / 32%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.payment-card label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.payment-card input {
|
||||
width: 100%;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
color: white;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.order-line,
|
||||
.payment-row,
|
||||
.card-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.order-line {
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.order-line strong {
|
||||
color: #7bffd4;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 0.8rem;
|
||||
padding-left: 0.8rem;
|
||||
color: rgb(255 255 255 / 38%);
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.card-number input {
|
||||
min-width: 4rem;
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pay-button,
|
||||
.confirmation-action {
|
||||
border: 0;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
color: #06110d;
|
||||
background: #70ffd0;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.pay-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.payment-card > small,
|
||||
.confirmation-card > small {
|
||||
align-self: center;
|
||||
color: rgb(255 255 255 / 40%);
|
||||
}
|
||||
|
||||
.confirmation-view {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.confirmation-card {
|
||||
display: flex;
|
||||
width: min(100%, 44rem);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirmation-mark {
|
||||
display: grid;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #06110d;
|
||||
background: #70ffd0;
|
||||
box-shadow: 0 0 0 0.8rem rgb(112 255 208 / 9%);
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.confirmation-card code {
|
||||
color: #8edfff;
|
||||
}
|
||||
|
||||
.confirmation-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirmation-card dl > div {
|
||||
padding: 0.9rem;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.confirmation-card dl > div + div {
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.confirmation-card dt {
|
||||
color: rgb(255 255 255 / 42%);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.confirmation-card dd {
|
||||
margin: 0.3rem 0 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
/* Connected partial drawer */
|
||||
|
||||
.partial-origin-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #10131a;
|
||||
}
|
||||
|
||||
.partial-origin-rail {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: max(1.5rem, env(safe-area-inset-top)) clamp(0.8rem, 3vw, 2rem)
|
||||
max(1.5rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 15%,
|
||||
rgb(255 132 74 / 30%),
|
||||
transparent 18rem
|
||||
),
|
||||
#19151a;
|
||||
}
|
||||
|
||||
.partial-brand {
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
color: #261006;
|
||||
background: #ff8c57;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.partial-origin-rail nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.partial-origin-rail nav button {
|
||||
border: 0;
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.75rem;
|
||||
color: rgb(255 255 255 / 62%);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.partial-origin-rail nav button:first-child {
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 9%);
|
||||
}
|
||||
|
||||
.partial-origin-rail .instance-card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.partial-origin-rail .instance-card strong {
|
||||
grid-row: auto;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.partial-origin-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: max(1.5rem, env(safe-area-inset-top))
|
||||
max(1.5rem, env(safe-area-inset-right))
|
||||
max(1.5rem, env(safe-area-inset-bottom)) clamp(1.5rem, 5vw, 5rem);
|
||||
background:
|
||||
linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 3%) 1px, transparent 1px), #0b0e14;
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.partial-origin-content > header,
|
||||
.partial-drawer-panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.partial-origin-content > header button,
|
||||
.partial-drawer-panel > header button {
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.8rem;
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.partial-origin-content > header span {
|
||||
color: rgb(255 255 255 / 38%);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.partial-origin-copy {
|
||||
display: flex;
|
||||
max-width: 48rem;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.partial-origin-copy h1,
|
||||
.partial-drawer-panel h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(3.5rem, 8vw, 7rem);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.partial-origin-copy p,
|
||||
.partial-drawer-panel > p {
|
||||
max-width: 38rem;
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 62%);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.partial-drawer-layer {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.partial-drawer-panel {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.4rem;
|
||||
padding: max(1.5rem, env(safe-area-inset-top)) clamp(1.2rem, 5vw, 4rem)
|
||||
max(1.5rem, env(safe-area-inset-bottom))
|
||||
max(1.2rem, env(safe-area-inset-left));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 12% 12%,
|
||||
rgb(255 141 88 / 28%),
|
||||
transparent 25rem
|
||||
),
|
||||
#18151b;
|
||||
box-shadow: 24px 0 80px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.partial-drawer-panel > header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.partial-drawer-panel > header button {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
padding: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.partial-drawer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.partial-drawer-list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
text-align: left;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.partial-drawer-list button span {
|
||||
color: #ff9b6c;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.partial-drawer-panel > small {
|
||||
color: rgb(255 255 255 / 38%);
|
||||
}
|
||||
|
||||
.partial-drawer-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
/* Scene inspector */
|
||||
|
||||
.scene-debug {
|
||||
@@ -501,6 +947,10 @@ summary:focus-visible {
|
||||
color: #8edfff;
|
||||
}
|
||||
|
||||
.node-state--exposed {
|
||||
color: #ffad7f;
|
||||
}
|
||||
|
||||
.debug-content code {
|
||||
color: #8edfff;
|
||||
}
|
||||
@@ -1949,6 +2399,78 @@ summary:focus-visible {
|
||||
.nested-page-gesture {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkout-layout {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.checkout-copy h1,
|
||||
.confirmation-card h1 {
|
||||
font-size: clamp(3.2rem, 16vw, 5.4rem);
|
||||
}
|
||||
|
||||
.checkout-step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkout-toolbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.confirmation-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.confirmation-card dl > div + div {
|
||||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.partial-origin-rail {
|
||||
gap: 1rem;
|
||||
padding-right: 0.6rem;
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
|
||||
.partial-origin-rail nav button {
|
||||
padding: 0.55rem 0.35rem;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.partial-origin-rail .instance-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.partial-origin-content {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.partial-origin-copy h1,
|
||||
.partial-drawer-panel h1 {
|
||||
font-size: clamp(2.8rem, 13vw, 4.8rem);
|
||||
}
|
||||
|
||||
.partial-drawer-panel {
|
||||
padding-right: 1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.partial-drawer-panel > header {
|
||||
position: relative;
|
||||
padding-right: 2.8rem;
|
||||
}
|
||||
|
||||
.partial-drawer-panel > header button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.partial-drawer-panel h1 {
|
||||
font-size: clamp(2.15rem, 10vw, 3.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
|
||||
@@ -31,13 +31,11 @@ const createSecond = () => above(secondView(), slideLeft);
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
const forwardGesture = gesture
|
||||
.to.left()
|
||||
.navigate(() => above(secondView()))
|
||||
.animate(slideLeft);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
@@ -17,6 +17,8 @@ import EdgePredicateView from "./EdgePredicateView.vue";
|
||||
import FirstView from "./FirstView.vue";
|
||||
import GalleryView from "./GalleryView.vue";
|
||||
import NestedScenesView from "./NestedScenesView.vue";
|
||||
import PaymentDetailsView from "./PaymentDetailsView.vue";
|
||||
import PartialDrawerPageView from "./PartialDrawerPageView.vue";
|
||||
import PlayerView from "./PlayerView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
@@ -60,6 +62,10 @@ const openPredicate = () =>
|
||||
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
|
||||
const openNestedScenes = () =>
|
||||
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
|
||||
const openCheckout = () =>
|
||||
actionFor(PaymentDetailsView, "Payment Details", slideLeft);
|
||||
const openPartialDrawer = () =>
|
||||
actionFor(PartialDrawerPageView, "Partial Drawer", focusPortal);
|
||||
|
||||
const labs = [
|
||||
{
|
||||
@@ -123,6 +129,24 @@ const labs = [
|
||||
action: openNestedScenes,
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
number: "08",
|
||||
title: "Replace checkout",
|
||||
description:
|
||||
"Confirm a mock payment, replace its mounted entry, then test where back returns.",
|
||||
tags: ["atomic replace", "history rewrite"],
|
||||
action: openCheckout,
|
||||
accent: "teal",
|
||||
},
|
||||
{
|
||||
number: "09",
|
||||
title: "Partial drawer",
|
||||
description:
|
||||
"Push a connected drawer two-thirds across while the live source occupies the final third.",
|
||||
tags: ["persistent effects", "partial view"],
|
||||
action: openPartialDrawer,
|
||||
accent: "orange",
|
||||
},
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
|
||||
74
apps/origins-demo/src/views/OrderConfirmationView.vue
Normal file
74
apps/origins-demo/src/views/OrderConfirmationView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
defineProps<{
|
||||
orderReference: string;
|
||||
replacedInstance: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Confirmation");
|
||||
|
||||
const returnGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture
|
||||
class="checkout-view confirmation-view"
|
||||
:gesture="returnGesture"
|
||||
>
|
||||
<section class="confirmation-card">
|
||||
<div class="confirmation-mark" aria-hidden="true">✓</div>
|
||||
<div class="eyebrow">Payment complete</div>
|
||||
<h1>Order confirmed.</h1>
|
||||
<p>
|
||||
The Payment Details node <code>{{ replacedInstance }}</code> has been
|
||||
removed. Back now resolves the retained Hub instance directly.
|
||||
</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Order</dt>
|
||||
<dd>{{ orderReference }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>History depth</dt>
|
||||
<dd>{{ origin.context.value.history.length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Previous view</dt>
|
||||
<dd>{{ origin.context.value.previous?.name ?? "None" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<InstanceCard
|
||||
label="Confirmation instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="confirmation-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
Back to labs
|
||||
</button>
|
||||
<small>Try the left-edge back gesture too.</small>
|
||||
</section>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
99
apps/origins-demo/src/views/PartialDrawerPageView.vue
Normal file
99
apps/origins-demo/src/views/PartialDrawerPageView.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGestureSurface,
|
||||
back,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { partialDrawerOpen } from "../motions";
|
||||
import PartialDrawerView from "./PartialDrawerView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Drawer page");
|
||||
|
||||
const drawerView = () =>
|
||||
originView(
|
||||
PartialDrawerView,
|
||||
{ openedFrom: identity.instance },
|
||||
{ key: "partial-drawer", name: "Two-thirds Drawer" },
|
||||
);
|
||||
|
||||
const openDrawerGesture = gesture.from
|
||||
.left("clamp(32px, 10%, 88px)")
|
||||
.to.right()
|
||||
.navigate(() => forward(drawerView()))
|
||||
.animate(partialDrawerOpen);
|
||||
|
||||
const gestures = [openDrawerGesture] as const;
|
||||
|
||||
function openDrawer() {
|
||||
void origin.perform(forward(drawerView(), partialDrawerOpen));
|
||||
}
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="partial-origin-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<!--
|
||||
This rail is the left third of the full page. Once the whole page moves
|
||||
right by 2/3, this exact live DOM remains visible in the final third.
|
||||
-->
|
||||
<aside class="partial-origin-rail">
|
||||
<div class="partial-brand">OV</div>
|
||||
<nav aria-label="Example navigation">
|
||||
<button type="button" data-origin-gesture="ignore">Inbox</button>
|
||||
<button type="button" data-origin-gesture="ignore">Projects</button>
|
||||
<button type="button" data-origin-gesture="ignore">Archive</button>
|
||||
</nav>
|
||||
<InstanceCard
|
||||
label="Page instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section class="partial-origin-content">
|
||||
<header>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Return to labs"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<span>Connected presentation</span>
|
||||
</header>
|
||||
|
||||
<div class="partial-origin-copy">
|
||||
<div class="eyebrow">Partial route lab</div>
|
||||
<h1>Keep one third of this page on screen.</h1>
|
||||
<p>
|
||||
Drag right from the left edge. The drawer becomes a real history
|
||||
entry, while this same mounted page remains translated and visible.
|
||||
</p>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="openDrawer"
|
||||
>
|
||||
Open two-thirds drawer
|
||||
</button>
|
||||
<div class="gesture-hint"><span>→</span> Pull from the left edge</div>
|
||||
</div>
|
||||
</section>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
84
apps/origins-demo/src/views/PartialDrawerView.vue
Normal file
84
apps/origins-demo/src/views/PartialDrawerView.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { partialDrawerClose } from "../motions";
|
||||
|
||||
defineProps<{ openedFrom: string }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Drawer");
|
||||
|
||||
const closeGesture = gesture.to
|
||||
.left()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(partialDrawerClose);
|
||||
|
||||
function closeDrawer() {
|
||||
if (origin.context.value.canGoBack)
|
||||
void origin.perform(back(partialDrawerClose));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="partial-drawer-layer" :gesture="closeGesture">
|
||||
<aside class="partial-drawer-panel">
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">Two-thirds destination</div>
|
||||
<h1>Workspace</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close drawer"
|
||||
@click="closeDrawer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
This drawer is its own Vue view and history entry. It did not copy,
|
||||
teleport, or remount the page visible beside it.
|
||||
</p>
|
||||
|
||||
<div class="partial-drawer-list">
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>01</span>
|
||||
Recent activity
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>02</span>
|
||||
Shared with me
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">
|
||||
<span>03</span>
|
||||
Offline files
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Drawer instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<small>
|
||||
Opened over {{ openedFrom }} · swipe left or tap the exposed third to
|
||||
close.
|
||||
</small>
|
||||
</aside>
|
||||
|
||||
<button
|
||||
class="partial-drawer-backdrop"
|
||||
type="button"
|
||||
aria-label="Close drawer from exposed page"
|
||||
@click="closeDrawer"
|
||||
/>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
156
apps/origins-demo/src/views/PaymentDetailsView.vue
Normal file
156
apps/origins-demo/src/views/PaymentDetailsView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
OriginGestureSurface,
|
||||
back,
|
||||
gesture,
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import OrderConfirmationView from "./OrderConfirmationView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Payment");
|
||||
const cardholder = ref("Ada Lovelace");
|
||||
const lastFour = ref("4242");
|
||||
const processing = ref(false);
|
||||
|
||||
function confirmationView() {
|
||||
return originView(
|
||||
OrderConfirmationView,
|
||||
{
|
||||
orderReference: `NVO-${Math.floor(Date.now() / 1000)
|
||||
.toString()
|
||||
.slice(-6)}`,
|
||||
replacedInstance: identity.instance,
|
||||
},
|
||||
{ key: "order-confirmation", name: "Order Confirmation" },
|
||||
);
|
||||
}
|
||||
|
||||
const confirmGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => replace(confirmationView()))
|
||||
.animate(slideLeft);
|
||||
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
const gestures = [confirmGesture, backGesture] as const;
|
||||
|
||||
function returnToLabs() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (processing.value) return;
|
||||
processing.value = true;
|
||||
|
||||
// `replace` is one atomic scene operation. Payment Details stays mounted
|
||||
// throughout the animation and is removed only after the commit completes.
|
||||
await origin.perform(replace(confirmationView(), slideLeft));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="checkout-view payment-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<header class="checkout-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Return to labs"
|
||||
@click="returnToLabs"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span>Secure checkout</span>
|
||||
<span class="checkout-step">Step 2 of 2</span>
|
||||
</header>
|
||||
|
||||
<section class="checkout-layout">
|
||||
<div class="checkout-copy">
|
||||
<div class="eyebrow">Replace-history lab</div>
|
||||
<h1>Payment details</h1>
|
||||
<p>
|
||||
Completing this mock payment replaces this mounted entry. The
|
||||
confirmation page will point straight back to the Hub.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="Payment instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form class="payment-card" @submit.prevent="pay">
|
||||
<div class="order-line">
|
||||
<span>Origin Pro</span>
|
||||
<strong>$48.00</strong>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Cardholder
|
||||
<input
|
||||
v-model="cardholder"
|
||||
data-origin-gesture="ignore"
|
||||
autocomplete="cc-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Card number
|
||||
<span class="card-number">
|
||||
<b>•••• •••• ••••</b>
|
||||
<input
|
||||
v-model="lastFour"
|
||||
data-origin-gesture="ignore"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
autocomplete="cc-number"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<div class="payment-row">
|
||||
<label>
|
||||
Expiry
|
||||
<input
|
||||
data-origin-gesture="ignore"
|
||||
value="08 / 29"
|
||||
autocomplete="cc-exp"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CVC
|
||||
<input
|
||||
data-origin-gesture="ignore"
|
||||
value="123"
|
||||
maxlength="4"
|
||||
autocomplete="cc-csc"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pay-button"
|
||||
type="submit"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ processing ? "Confirming…" : "Pay $48.00" }}
|
||||
</button>
|
||||
<small>Or swipe left anywhere outside the form controls.</small>
|
||||
</form>
|
||||
</section>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
@@ -63,6 +63,40 @@ not construct X again from its recipe. Cancelling a forward edge restores its
|
||||
source and removes the newly created target branch; cancelling a back edge
|
||||
re-parks its retained target.
|
||||
|
||||
Committing a replace from Y to Z creates Z but links it directly to Y's
|
||||
previous entry:
|
||||
|
||||
```text
|
||||
Before: X (parked) ← Y (visible)
|
||||
After: X (parked) ← Z (visible)
|
||||
```
|
||||
|
||||
Y remains mounted while the operation is interactive and is removed only on
|
||||
commit. Cancelling removes Z and restores Y, making replacement one atomic
|
||||
operation rather than a visible back followed by a forward push.
|
||||
|
||||
### Connected partial presentations
|
||||
|
||||
Most committed pushes collapse their temporary operation edge and park the
|
||||
retained source. A choreography with `persistAtRest: true` instead keeps its
|
||||
progress-`1` effects as a settled coordinate relationship:
|
||||
|
||||
```text
|
||||
Page (exposed, inert) → Drawer (active)
|
||||
```
|
||||
|
||||
This supports partial destinations without copying or remounting either Vue
|
||||
component. For a two-thirds drawer, the settled source effect translates the
|
||||
page right by two thirds, while the target finishes at its identity position.
|
||||
The page's left third therefore remains physically visible in the final third
|
||||
of the viewport.
|
||||
|
||||
Settled relationships are not live operations and are omitted from the public
|
||||
operation diagnostics. Beginning back temporarily suspends the relationship
|
||||
and lets a reciprocal close choreography start at the same endpoints. A
|
||||
cancelled close restores the settled edge; a committed close removes the drawer
|
||||
and returns the page to its normal root position.
|
||||
|
||||
## Interaction
|
||||
|
||||
Gesture recognition is declared within each component through
|
||||
@@ -101,5 +135,6 @@ X (parked) ← Y (parked) ← Z (visible)
|
||||
|
||||
Back targets the previous node key directly. A node is unmounted only when a
|
||||
committed back operation pops it, a forward operation is cancelled, or the
|
||||
whole scene is destroyed. Because the same DOM survives parking, nested scroll
|
||||
positions and component-local state survive without `<KeepAlive>`.
|
||||
current entry is successfully replaced, or the whole scene is destroyed.
|
||||
Because the same DOM survives parking, nested scroll positions and
|
||||
component-local state survive without `<KeepAlive>`.
|
||||
|
||||
@@ -305,10 +305,37 @@ again and leaves the current entry active.
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"under"`.
|
||||
|
||||
### `replace(target, choreography?, options?)`
|
||||
|
||||
Creates a new target while removing the current history entry:
|
||||
|
||||
```ts
|
||||
const confirmOrder = () =>
|
||||
replace(
|
||||
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
|
||||
slideLeft,
|
||||
);
|
||||
```
|
||||
|
||||
The target inherits the origin's `previousNodeKey`, so a later back skips the
|
||||
replaced entry. The origin remains mounted while the operation is interactive
|
||||
or settling and is unmounted only after commit. Cancelling removes the proposed
|
||||
target and restores the origin without changing history.
|
||||
|
||||
Without choreography, `replace(target)` creates an intent suitable for a
|
||||
gesture builder:
|
||||
|
||||
```ts
|
||||
.navigate(() => replace(originView(OrderConfirmationView)))
|
||||
.animate(slideLeft)
|
||||
```
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||||
|
||||
### `originAction(target, choreography, options?)`
|
||||
|
||||
Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()`
|
||||
when expressing retained navigation.
|
||||
Constructs a complete low-level `OriginAction`. Prefer `forward()`, `replace()`,
|
||||
and `back()` when expressing retained navigation.
|
||||
|
||||
```ts
|
||||
const action = originAction(profile, slideLeft, {
|
||||
@@ -353,8 +380,9 @@ Omitting choreography returns a navigation intent for a gesture builder.
|
||||
History is a linked chain of mounted scene nodes.
|
||||
|
||||
| Mode | Commit behavior |
|
||||
| -------- | -------------------------------------------------------------- |
|
||||
| ----------- | -------------------------------------------------------------- |
|
||||
| `"push"` | Park and retain the origin; activate the new target |
|
||||
| `"replace"` | Create a new target, inherit prior history, unmount the origin |
|
||||
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
|
||||
|
||||
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
|
||||
@@ -476,6 +504,34 @@ const scaleIn = defineOriginChoreography({
|
||||
The function returns the same object. Its value is type checking and a clear
|
||||
construction point.
|
||||
|
||||
Set `persistAtRest: true` when progress `1` should remain as a connected visual
|
||||
relationship after a committed push:
|
||||
|
||||
```ts
|
||||
const openPartialDrawer = defineOriginChoreography({
|
||||
name: "partial-drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translateX(${progress * 66.6667}%)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translateX(${(progress - 1) * 66.6667}%)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
This leaves the retained source mounted, visible, and inert instead of parking
|
||||
it. The target remains the active history entry. Beginning back suspends the
|
||||
resting relationship so a reciprocal close choreography can take over;
|
||||
cancelling back restores it exactly.
|
||||
|
||||
Connected resting effects are supported only by retained-history push actions.
|
||||
They are designed for partial drawers, inspectors, and other presentations
|
||||
where both mounted views remain visible after commit. They do not appear in
|
||||
`scene.operations`, which reports live interactive/settling edges only.
|
||||
|
||||
`effects()` may return:
|
||||
|
||||
| Effect | Applied to |
|
||||
@@ -566,9 +622,9 @@ exists.
|
||||
|
||||
### `scene.begin(originKey, action)`
|
||||
|
||||
For forward, mounts a new target and waits one Vue tick for measurement. For
|
||||
back, reveals and measures the retained previous node. It then returns an
|
||||
`OriginOperationHandle`.
|
||||
For forward or replace, mounts a new target and waits one Vue tick for
|
||||
measurement. For back, reveals and measures the retained previous node. It
|
||||
then returns an `OriginOperationHandle`.
|
||||
|
||||
```ts
|
||||
const handle = await scene.begin(nodeKey, action);
|
||||
@@ -624,9 +680,9 @@ A choreography, placement, history mode, and—except for back—target recipe.
|
||||
### `OriginNavigationIntent`
|
||||
|
||||
An animation-free target, placement, and history mutation returned by
|
||||
`forward()`, `back()`, `above()`, or `under()` when choreography is omitted.
|
||||
Gesture `.animate()` combines it with choreography to create the internal
|
||||
action.
|
||||
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
|
||||
is omitted. Gesture `.animate()` combines it with choreography to create the
|
||||
internal action.
|
||||
|
||||
### Gesture definition types
|
||||
|
||||
@@ -688,7 +744,7 @@ Rect values are viewport CSS pixels.
|
||||
|
||||
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
|
||||
history, and incoming edge.
|
||||
- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`.
|
||||
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
|
||||
- `OriginOperation`: read-only live edge state.
|
||||
- `OriginOperationPhase`: operation lifecycle phase.
|
||||
- `OriginOperationIntent`: selected operation outcome.
|
||||
@@ -705,7 +761,8 @@ implementation types. They are exported by the current barrel but marked
|
||||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||
`OriginScene`.
|
||||
- An origin can own only one outgoing operation at a time.
|
||||
- Parked history entries cannot originate operations until back reveals them.
|
||||
- Parked or exposed retained entries are inert and cannot originate operations.
|
||||
The active connected target owns interactions until back reveals its source.
|
||||
- A target can originate its own operation as soon as its incoming operation's
|
||||
intent becomes commit.
|
||||
- The included recognizer follows one primary pointer and one axis.
|
||||
|
||||
@@ -21,16 +21,20 @@ From the workspace root:
|
||||
npm run dev:v2
|
||||
```
|
||||
|
||||
Open the printed URL to explore seven physical labs:
|
||||
Open the printed URL to explore nine physical labs:
|
||||
|
||||
- a four-view chain that can keep four nodes and three edges live at once;
|
||||
- one view with horizontal, vertical, and edge-only declarations;
|
||||
- programmatic gallery navigation followed by gesture-owned traversal;
|
||||
- a vertically presented media player with local interactive state;
|
||||
- a chat that intentionally declares no back gesture;
|
||||
- a predicate-gated downward gesture that drops a left-edge dialog.
|
||||
- a predicate-gated downward gesture that drops a left-edge dialog;
|
||||
- three nested scenes demonstrating cooperative carousels, vertical decks, and
|
||||
an intentional parent/child gesture conflict.
|
||||
an intentional parent/child gesture conflict; and
|
||||
- a checkout flow that replaces Payment Details with Confirmation and proves
|
||||
that back returns directly to the retained Hub instance; and
|
||||
- a connected two-thirds drawer that keeps the translated source page visible
|
||||
in the exposed final third.
|
||||
|
||||
The expandable inspector reports mounted Vue instances, active operation
|
||||
edges, animation progress, and recent lifecycle events. In the chain lab,
|
||||
@@ -156,6 +160,46 @@ The application chooses whether this is exposed as a left-edge gesture,
|
||||
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
|
||||
at all.
|
||||
|
||||
## Replacing the current entry
|
||||
|
||||
Use `replace()` for completed one-way flows such as Payment Details →
|
||||
Confirmation:
|
||||
|
||||
```ts
|
||||
import {
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import OrderConfirmationView from "./OrderConfirmationView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
|
||||
function confirmOrder() {
|
||||
return origin.perform(
|
||||
replace(
|
||||
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
|
||||
slideLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The replacement inherits the current entry's mounted predecessor. It does not
|
||||
retain the entry being replaced, so back from Confirmation skips Payment
|
||||
Details. The mutation is atomic: a cancelled interactive replacement removes
|
||||
the proposed Confirmation and restores Payment Details unchanged.
|
||||
|
||||
Inside a gesture builder, omit choreography from the intent:
|
||||
|
||||
```ts
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => replace(originView(OrderConfirmationView)))
|
||||
.animate(slideLeft);
|
||||
```
|
||||
|
||||
## Custom choreography
|
||||
|
||||
A choreography returns independent effects for its source, target, and their
|
||||
@@ -195,6 +239,23 @@ Y transform = (X→Y target) × (Y→Z source)
|
||||
Z transform = (X→Y target) × (Y→Z target)
|
||||
```
|
||||
|
||||
For partial presentations that must keep both views visible after commit, set
|
||||
`persistAtRest: true` on the opening choreography:
|
||||
|
||||
```ts
|
||||
const openDrawer = defineOriginChoreography({
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The retained source becomes visible-but-inert rather than parked. A reciprocal
|
||||
back choreography closes the target; a cancelled close restores the connected
|
||||
resting effects and both original Vue instances.
|
||||
|
||||
## Why scene nodes are flat
|
||||
|
||||
The operation graph is not represented as Vue component ancestry. Every
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
|
||||
import OriginScene from "./components/OriginScene.vue";
|
||||
import { gesture, useOriginGesture } from "./gesture";
|
||||
import { back, defineOriginChoreography, forward } from "./motion";
|
||||
import { back, defineOriginChoreography, forward, replace } from "./motion";
|
||||
import { createOriginScene, originView } from "./scene";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
@@ -109,6 +109,11 @@ describe("gesture builder", () => {
|
||||
placement: "under",
|
||||
history: "back",
|
||||
});
|
||||
expect(replace(target)).toEqual({
|
||||
target,
|
||||
placement: "above",
|
||||
history: "replace",
|
||||
});
|
||||
expect(forward(target, testMotion)).toMatchObject({
|
||||
target,
|
||||
choreography: testMotion,
|
||||
@@ -118,6 +123,11 @@ describe("gesture builder", () => {
|
||||
choreography: testMotion,
|
||||
history: "back",
|
||||
});
|
||||
expect(replace(target, testMotion)).toMatchObject({
|
||||
target,
|
||||
choreography: testMotion,
|
||||
history: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {
|
||||
|
||||
@@ -56,7 +56,8 @@ export interface OriginNavigationActionOptions {
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"` for {@link forward}, `"under"` for {@link back}
|
||||
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
||||
* for {@link back}
|
||||
*/
|
||||
placement?: OriginPlacement;
|
||||
}
|
||||
@@ -135,6 +136,50 @@ export function forward(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retained-history replacement intent or complete action.
|
||||
*
|
||||
* The target inherits the origin's previous mounted entry instead of retaining
|
||||
* the origin itself. A commit unmounts the origin and activates the target, so
|
||||
* a later {@link back} skips the replaced entry. A cancellation removes the
|
||||
* proposed target and restores the origin unchanged.
|
||||
*
|
||||
* Omit choreography when declaring `.navigate()` inside a gesture builder;
|
||||
* provide choreography when passing the result directly to `begin()` or
|
||||
* `perform()`.
|
||||
*
|
||||
* @param target - View recipe created for the replacement.
|
||||
* @param options - Stacking options for an animation-free navigation intent.
|
||||
* @returns An animation-free replacement intent for use with a gesture builder.
|
||||
*/
|
||||
export function replace(
|
||||
target: OriginView,
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginNavigationIntent;
|
||||
export function replace(
|
||||
target: OriginView,
|
||||
choreography: OriginChoreography,
|
||||
options?: OriginNavigationActionOptions,
|
||||
): OriginAction;
|
||||
export function replace(
|
||||
target: OriginView,
|
||||
choreographyOrOptions:
|
||||
OriginChoreography | OriginNavigationActionOptions = {},
|
||||
options: OriginNavigationActionOptions = {},
|
||||
): OriginAction | OriginNavigationIntent {
|
||||
if (isChoreography(choreographyOrOptions)) {
|
||||
return originAction(target, choreographyOrOptions, {
|
||||
placement: options.placement ?? "above",
|
||||
history: "replace",
|
||||
});
|
||||
}
|
||||
return {
|
||||
target,
|
||||
placement: choreographyOrOptions.placement ?? "above",
|
||||
history: "replace",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retained-history back intent or complete action.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
back,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
replace,
|
||||
under,
|
||||
} from "./motion";
|
||||
import { createOriginScene, originView } from "./scene";
|
||||
@@ -40,6 +41,23 @@ const layeredMotion = defineOriginChoreography({
|
||||
}),
|
||||
});
|
||||
|
||||
const partialDrawerOpen = defineOriginChoreography({
|
||||
name: "test-partial-drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||
}),
|
||||
});
|
||||
|
||||
const partialDrawerClose = defineOriginChoreography({
|
||||
name: "test-partial-drawer-close",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
||||
}),
|
||||
});
|
||||
|
||||
describe("origin-relative scene graph", () => {
|
||||
it("composes X→Y and Y→Z as independent transform layers", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
@@ -290,6 +308,215 @@ describe("origin-relative scene graph", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the current entry and makes back skip the removed instance", async () => {
|
||||
let paymentUnmounts = 0;
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const Payment = defineComponent({
|
||||
name: "Payment",
|
||||
setup() {
|
||||
onUnmounted(() => {
|
||||
paymentUnmounts += 1;
|
||||
});
|
||||
return () => h("div", "Payment");
|
||||
},
|
||||
});
|
||||
const payment = originView(Payment, undefined, { key: "payment" });
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
await openPayment.finish({ commit: true, animate: false });
|
||||
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Confirmation", "active"],
|
||||
]);
|
||||
expect(paymentUnmounts).toBe(1);
|
||||
|
||||
const confirmationContext = scene.contextFor(confirmPayment.targetKey);
|
||||
expect(confirmationContext.previous).toBe(home);
|
||||
expect(confirmationContext.history).toEqual([home]);
|
||||
|
||||
const returnHome = await scene.begin(
|
||||
confirmPayment.targetKey,
|
||||
back(layeredMotion),
|
||||
);
|
||||
expect(returnHome.targetKey).toBe(homeKey);
|
||||
await returnHome.finish({ commit: true, animate: false });
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual(["Home"]);
|
||||
});
|
||||
|
||||
it("restores the current entry when an interactive replace is cancelled", async () => {
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const payment = originView(component("Payment"), undefined, {
|
||||
key: "payment",
|
||||
});
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
await openPayment.finish({ commit: true, animate: false });
|
||||
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.cancel({ animate: false });
|
||||
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Payment", "active"],
|
||||
]);
|
||||
expect(scene.contextFor(openPayment.targetKey).history).toEqual([home]);
|
||||
});
|
||||
|
||||
it("can replace an initial root without manufacturing history", async () => {
|
||||
const scene = createOriginScene({
|
||||
initial: originView(component("Welcome"), undefined, { key: "welcome" }),
|
||||
});
|
||||
const welcomeKey = scene.nodes.value[0]!.key;
|
||||
const replacement = await scene.begin(
|
||||
welcomeKey,
|
||||
replace(
|
||||
originView(component("Signed in"), undefined, { key: "signed-in" }),
|
||||
layeredMotion,
|
||||
),
|
||||
);
|
||||
await replacement.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.roots.value).toEqual([replacement.targetKey]);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"Signed in",
|
||||
]);
|
||||
expect(scene.contextFor(replacement.targetKey)).toMatchObject({
|
||||
canGoBack: false,
|
||||
history: [],
|
||||
previous: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("can replace a target while its incoming push is still settling", async () => {
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const payment = originView(component("Payment"), undefined, {
|
||||
key: "payment",
|
||||
});
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
openPayment.update(0.8, 1);
|
||||
|
||||
const paymentSettlement = openPayment.finish({ commit: true });
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.finish({ commit: true, animate: false });
|
||||
await paymentSettlement;
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Confirmation", "active"],
|
||||
]);
|
||||
expect(scene.contextFor(confirmPayment.targetKey).history).toEqual([home]);
|
||||
});
|
||||
|
||||
it("keeps a partial push connected at rest and restores it after cancelled back", async () => {
|
||||
const page = originView(component("Page"), undefined, { key: "page" });
|
||||
const drawer = originView(component("Drawer"), undefined, {
|
||||
key: "drawer",
|
||||
});
|
||||
const scene = createOriginScene({ initial: page });
|
||||
const pageKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const openDrawer = await scene.begin(
|
||||
pageKey,
|
||||
forward(drawer, partialDrawerOpen),
|
||||
);
|
||||
await openDrawer.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Page", "exposed"],
|
||||
["Drawer", "active"],
|
||||
]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
expect(scene.styleForNode(pageKey).pointerEvents).toBe("none");
|
||||
expect(scene.styleForNode(openDrawer.targetKey).transform).toBe(
|
||||
"translateX(0%)",
|
||||
);
|
||||
|
||||
const closeDrawer = await scene.begin(
|
||||
openDrawer.targetKey,
|
||||
back(partialDrawerClose),
|
||||
);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
await closeDrawer.cancel({ animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Page", "exposed"],
|
||||
["Drawer", "active"],
|
||||
]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
|
||||
const committedClose = await scene.begin(
|
||||
openDrawer.targetKey,
|
||||
back(partialDrawerClose),
|
||||
);
|
||||
await committedClose.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||
[pageKey, "active"],
|
||||
]);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("none");
|
||||
});
|
||||
|
||||
it("hands an immediate back gesture from a settling push to the same instances", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
|
||||
@@ -155,6 +155,12 @@ export function createOriginScene(
|
||||
): OriginScene {
|
||||
const nodes = shallowReactive(new Map<string, SceneNodeState>());
|
||||
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
|
||||
/*
|
||||
* A close gesture temporarily removes a connected resting edge to avoid a
|
||||
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
|
||||
* us restore the exact same edge and component instances.
|
||||
*/
|
||||
const suspendedPresentations = new Map<number, MutableOriginOperation>();
|
||||
const roots = shallowRef<string[]>([]);
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
let container: HTMLElement | null = null;
|
||||
@@ -222,6 +228,10 @@ export function createOriginScene(
|
||||
switch (action.history ?? "push") {
|
||||
case "push":
|
||||
return origin.key;
|
||||
case "replace":
|
||||
// Skip the origin in the retained chain. The old entry remains mounted
|
||||
// until commit so an interactive replacement can still be cancelled.
|
||||
return origin.previousNodeKey;
|
||||
case "back":
|
||||
return origin.previousNodeKey;
|
||||
}
|
||||
@@ -275,9 +285,7 @@ export function createOriginScene(
|
||||
|
||||
function visualEffects(nodeKey: string) {
|
||||
const result = inheritedEffects(nodeKey);
|
||||
const outgoing = [...operations.values()].find(
|
||||
(operation) => operation.originKey === nodeKey,
|
||||
);
|
||||
const outgoing = visualOutgoingFor(nodeKey);
|
||||
if (!outgoing) return result;
|
||||
const effects = effectSetFor(outgoing);
|
||||
return [
|
||||
@@ -287,6 +295,12 @@ export function createOriginScene(
|
||||
];
|
||||
}
|
||||
|
||||
function visualOutgoingFor(nodeKey: string) {
|
||||
return [...operations.values()].find(
|
||||
(operation) => operation.originKey === nodeKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse independent effect layers into one host style. Geometry remains
|
||||
* composable; arbitrary CSS properties use normal local-last precedence.
|
||||
@@ -350,13 +364,15 @@ export function createOriginScene(
|
||||
|
||||
function outgoingFor(nodeKey: string) {
|
||||
return [...operations.values()].find(
|
||||
(operation) => operation.originKey === nodeKey,
|
||||
(operation) =>
|
||||
operation.originKey === nodeKey && operation.phase !== "finished",
|
||||
);
|
||||
}
|
||||
|
||||
function isNodeInteractive(nodeKey: string) {
|
||||
const node = nodes.get(nodeKey);
|
||||
if (!node || node.state === "parked") return false;
|
||||
if (!node || node.state === "parked" || node.state === "exposed")
|
||||
return false;
|
||||
|
||||
// A target being cancelled is already scheduled to disappear. A source
|
||||
// settling toward commit has ceded new interactions to the retained scene
|
||||
@@ -407,9 +423,12 @@ export function createOriginScene(
|
||||
}
|
||||
|
||||
function refreshVisibleState(node: SceneNodeState) {
|
||||
if (node.state === "parked") return;
|
||||
if (node.state === "parked" || node.state === "exposed") return;
|
||||
const incoming = node.incomingOperationId
|
||||
? operations.get(node.incomingOperationId)
|
||||
: undefined;
|
||||
node.state =
|
||||
node.incomingOperationId || outgoingFor(node.key)
|
||||
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
|
||||
? "transitioning"
|
||||
: "active";
|
||||
}
|
||||
@@ -437,8 +456,9 @@ export function createOriginScene(
|
||||
/**
|
||||
* Finalize a committed operation.
|
||||
*
|
||||
* Push keeps the origin mounted and parks it. Back reuses the existing
|
||||
* previous node and removes only the entry being popped.
|
||||
* A normal push parks its origin and collapses the temporary visual edge.
|
||||
* A connected push keeps the edge at progress 1 and leaves the origin
|
||||
* exposed but inert. Replace and back remove the origin.
|
||||
*/
|
||||
function commitOperation(id: number) {
|
||||
const operation = operations.get(id);
|
||||
@@ -447,15 +467,28 @@ export function createOriginScene(
|
||||
const target = nodes.get(operation.targetKey);
|
||||
if (!origin || !target) return false;
|
||||
|
||||
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
||||
operation.progress = 1;
|
||||
operation.velocity = 0;
|
||||
operation.intent = "commit";
|
||||
operation.phase = "finished";
|
||||
origin.state = "exposed";
|
||||
target.state = "active";
|
||||
refreshVisibleState(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
spliceVisualTarget(origin, target);
|
||||
operation.phase = "finished";
|
||||
operations.delete(operation.id);
|
||||
suspendedPresentations.delete(operation.id);
|
||||
|
||||
if (operation.history === "push") {
|
||||
origin.incomingOperationId = undefined;
|
||||
origin.state = "parked";
|
||||
} else {
|
||||
// A committed back pops only the current retained history entry.
|
||||
// Back pops the current entry; replace discards it in favor of the new
|
||||
// target. Both preserve every earlier retained instance.
|
||||
removeNode(origin.key);
|
||||
}
|
||||
|
||||
@@ -469,11 +502,22 @@ export function createOriginScene(
|
||||
if (!operation) return;
|
||||
const origin = nodes.get(operation.originKey);
|
||||
const target = nodes.get(operation.entryTargetKey);
|
||||
const suspendedPresentation = suspendedPresentations.get(id);
|
||||
operation.phase = "finished";
|
||||
operations.delete(id);
|
||||
suspendedPresentations.delete(id);
|
||||
|
||||
if (operation.history === "back") {
|
||||
if (target) {
|
||||
if (suspendedPresentation && origin && target) {
|
||||
/*
|
||||
* Put the retained page back into its former parent/root position, then
|
||||
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
|
||||
*/
|
||||
spliceVisualTarget(origin, target);
|
||||
operations.set(suspendedPresentation.id, suspendedPresentation);
|
||||
origin.incomingOperationId = suspendedPresentation.id;
|
||||
target.state = "exposed";
|
||||
} else if (target) {
|
||||
target.incomingOperationId = undefined;
|
||||
target.state = "parked";
|
||||
}
|
||||
@@ -539,14 +583,14 @@ export function createOriginScene(
|
||||
) {
|
||||
const operation = operations.get(id);
|
||||
if (!operation) return false;
|
||||
if (operation.phase === "finished") return operation.intent === "commit";
|
||||
|
||||
const threshold = operation.choreography.commitThreshold ?? 0.36;
|
||||
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
|
||||
const shouldCommit =
|
||||
options.commit ??
|
||||
(operation.progress >= threshold ||
|
||||
(operation.progress >= 0.00 &&
|
||||
operation.velocity >= 0.3));
|
||||
(operation.progress >= 0.0 && operation.velocity >= 0.3));
|
||||
// operation.velocity >= velocityThreshold));
|
||||
|
||||
// The outcome is known synchronously at release. The target can therefore
|
||||
@@ -568,8 +612,10 @@ export function createOriginScene(
|
||||
const origin = nodes.get(originKey);
|
||||
if (!origin)
|
||||
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
||||
if (origin.state === "parked")
|
||||
throw new Error(`Cannot animate from parked origin "${originKey}".`);
|
||||
if (origin.state === "parked" || origin.state === "exposed")
|
||||
throw new Error(
|
||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||
);
|
||||
if (outgoingFor(originKey))
|
||||
throw new Error(
|
||||
`Origin "${originKey}" already has an outgoing operation. ` +
|
||||
@@ -577,7 +623,12 @@ export function createOriginScene(
|
||||
);
|
||||
|
||||
const history = action.history ?? "push";
|
||||
if (action.choreography.persistAtRest && history !== "push")
|
||||
throw new Error(
|
||||
"persistAtRest choreographies require retained-history push navigation.",
|
||||
);
|
||||
let target: SceneNodeState;
|
||||
let suspendedPresentation: MutableOriginOperation | undefined;
|
||||
|
||||
if (history === "back") {
|
||||
const previousKey = origin.previousNodeKey;
|
||||
@@ -606,7 +657,22 @@ export function createOriginScene(
|
||||
throw new Error(
|
||||
"Cannot go back through an undecided forward operation.",
|
||||
);
|
||||
commitOperation(incoming.id);
|
||||
if (incoming.phase !== "finished") commitOperation(incoming.id);
|
||||
|
||||
const connected = operations.get(incoming.id);
|
||||
if (
|
||||
connected?.phase === "finished" &&
|
||||
connected.choreography.persistAtRest
|
||||
) {
|
||||
/*
|
||||
* Re-root A→page→drawer as A→drawer before adding drawer→page.
|
||||
* The reverse choreography starts at the same visual endpoints.
|
||||
*/
|
||||
operations.delete(connected.id);
|
||||
spliceVisualTarget(retainedTarget, origin);
|
||||
retainedTarget.incomingOperationId = undefined;
|
||||
suspendedPresentation = connected;
|
||||
}
|
||||
}
|
||||
|
||||
if (outgoingFor(retainedTarget.key))
|
||||
@@ -645,6 +711,8 @@ export function createOriginScene(
|
||||
originRect: elementRect(elements.get(originKey)),
|
||||
});
|
||||
operations.set(operation.id, operation);
|
||||
if (suspendedPresentation)
|
||||
suspendedPresentations.set(operation.id, suspendedPresentation);
|
||||
|
||||
// Let Vue mount the target before measuring it. Gesture composables buffer
|
||||
// pointer progress while this short preparation step is pending.
|
||||
@@ -682,7 +750,10 @@ export function createOriginScene(
|
||||
})) as readonly OriginSceneNode[],
|
||||
),
|
||||
operations: computed(
|
||||
() => [...operations.values()] as readonly OriginOperation[],
|
||||
() =>
|
||||
[...operations.values()].filter(
|
||||
(operation) => operation.phase !== "finished",
|
||||
) as readonly OriginOperation[],
|
||||
),
|
||||
roots,
|
||||
contextFor,
|
||||
|
||||
@@ -48,9 +48,11 @@ export type OriginOperationIntent = "undecided" | "commit" | "cancel";
|
||||
* Controls how an operation participates in retained instance history.
|
||||
*
|
||||
* - `push`: create a target whose previous entry is the mounted origin.
|
||||
* - `replace`: create a target that inherits the origin's previous entry, then
|
||||
* remove the origin on commit.
|
||||
* - `back`: reuse the mounted previous entry and pop the origin on commit.
|
||||
*/
|
||||
export type OriginHistoryMode = "push" | "back";
|
||||
export type OriginHistoryMode = "push" | "replace" | "back";
|
||||
|
||||
/**
|
||||
* The target's stacking relationship to its origin while an operation exists.
|
||||
@@ -62,9 +64,12 @@ export type OriginPlacement = "above" | "under";
|
||||
*
|
||||
* - `active`: currently exposed for normal interaction.
|
||||
* - `transitioning`: participating in at least one live operation edge.
|
||||
* - `exposed`: retained and visually presented behind/beside the active entry,
|
||||
* but inert.
|
||||
* - `parked`: retained in history but visually hidden and inert.
|
||||
*/
|
||||
export type OriginSceneNodeState = "active" | "transitioning" | "parked";
|
||||
export type OriginSceneNodeState =
|
||||
"active" | "transitioning" | "exposed" | "parked";
|
||||
|
||||
/** A rectangle measured in viewport CSS pixels. */
|
||||
export interface OriginRect {
|
||||
@@ -172,13 +177,27 @@ export interface OriginChoreography {
|
||||
* @defaultValue `0.9`
|
||||
*/
|
||||
readonly commitVelocity?: number;
|
||||
/**
|
||||
* Keep this choreography's progress-`1` effects connected after a committed
|
||||
* push instead of collapsing the edge and hiding its source.
|
||||
*
|
||||
* This is intended for partial presentations such as drawers and inspectors
|
||||
* where part of the retained source remains visible beside the target. The
|
||||
* source remains mounted, visible, and inert. A back operation temporarily
|
||||
* suspends the resting edge; cancelling back restores it.
|
||||
*
|
||||
* Only retained-history `push` actions support connected resting effects.
|
||||
*
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
readonly persistAtRest?: boolean;
|
||||
}
|
||||
|
||||
/** A complete request to create and animate a target view from an origin. */
|
||||
export interface OriginAction {
|
||||
/**
|
||||
* Recipe for a newly created target. A back action omits this because the
|
||||
* scene resolves its already-mounted previous entry.
|
||||
* Recipe for a newly created push or replacement target. A back action omits
|
||||
* this because the scene resolves its already-mounted previous entry.
|
||||
*/
|
||||
readonly target?: OriginView;
|
||||
/** Visual relationship applied to the source, target, and shared frame. */
|
||||
@@ -206,8 +225,8 @@ export interface OriginAction {
|
||||
*/
|
||||
export interface OriginNavigationIntent {
|
||||
/**
|
||||
* Recipe for a newly created target. Back navigation omits this because the
|
||||
* scene resolves the retained previous instance.
|
||||
* Recipe for a newly created push or replacement target. Back navigation
|
||||
* omits this because the scene resolves the retained previous instance.
|
||||
*/
|
||||
readonly target?: OriginView;
|
||||
/** Target stacking relationship while the gesture operation is visible. */
|
||||
@@ -234,7 +253,11 @@ export interface OriginSceneNode {
|
||||
readonly previousNodeKey?: string;
|
||||
/** Current visibility/lifecycle role of this mounted instance. */
|
||||
readonly state: OriginSceneNodeState;
|
||||
/** Live operation currently positioning this node as its target. */
|
||||
/**
|
||||
* Operation edge positioning this node as its target. This may identify a
|
||||
* connected resting edge that is intentionally absent from live-operation
|
||||
* diagnostics.
|
||||
*/
|
||||
readonly incomingOperationId?: number;
|
||||
}
|
||||
|
||||
@@ -341,8 +364,8 @@ export interface OriginScene {
|
||||
* Create a forward target or reveal a retained back target, then return
|
||||
* manual control of its operation.
|
||||
*
|
||||
* @throws If the origin is missing, parked, or already owns an outgoing
|
||||
* operation.
|
||||
* @throws If the origin is missing, parked/exposed and inert, or already owns
|
||||
* an outgoing operation.
|
||||
*/
|
||||
begin(
|
||||
originKey: string,
|
||||
|
||||
Reference in New Issue
Block a user