diff --git a/.changeset/silly-rats-itch.md b/.changeset/silly-rats-itch.md new file mode 100644 index 00000000..989ac9d7 --- /dev/null +++ b/.changeset/silly-rats-itch.md @@ -0,0 +1,6 @@ +--- +"@stackflow/plugin-basic-ui": minor +"@stackflow/react-ui-core": minor +--- + +feat(react-ui-core, plugin-basic-ui): add `onSwipe*` hooks and add data attributes and css variables diff --git a/extensions/plugin-basic-ui/src/components/AppBar.tsx b/extensions/plugin-basic-ui/src/components/AppBar.tsx index 2a012c2d..cf0588b4 100644 --- a/extensions/plugin-basic-ui/src/components/AppBar.tsx +++ b/extensions/plugin-basic-ui/src/components/AppBar.tsx @@ -1,6 +1,8 @@ import { useActions } from "@stackflow/react"; import { + useActivityDataAttributes, useAppBarTitleMaxWidth, + useMounted, useNullableActivity, } from "@stackflow/react-ui-core"; import { assignInlineVars } from "@vanilla-extract/dynamic"; @@ -9,7 +11,6 @@ import { IconBack, IconClose } from "../assets"; import { useGlobalOptions } from "../basicUIPlugin"; import type { GlobalVars } from "../basicUIPlugin.css"; import { globalVars } from "../basicUIPlugin.css"; - import { compactMap } from "../utils"; import * as css from "./AppBar.css"; import * as appScreenCss from "./AppScreen.css"; @@ -88,6 +89,9 @@ const AppBar = forwardRef( ) => { const actions = useActions(); const activity = useNullableActivity(); + const activityDataAttributes = useActivityDataAttributes(); + + const mounted = useMounted(); const globalOptions = useGlobalOptions(); const globalCloseButton = globalOptions.appBar?.closeButton; @@ -292,6 +296,8 @@ const AppBar = forwardRef( [appScreenCss.vars.appBar.center.mainWidth]: `${maxWidth}px`, }), )} + data-part="appBar" + {...activityDataAttributes} >
diff --git a/extensions/plugin-basic-ui/src/components/AppScreen.tsx b/extensions/plugin-basic-ui/src/components/AppScreen.tsx index 7bb81de6..655b1ab0 100644 --- a/extensions/plugin-basic-ui/src/components/AppScreen.tsx +++ b/extensions/plugin-basic-ui/src/components/AppScreen.tsx @@ -1,8 +1,6 @@ import { useActions } from "@stackflow/react"; -import { assignInlineVars } from "@vanilla-extract/dynamic"; -import { createContext, useContext, useMemo, useRef } from "react"; - import { + useActivityDataAttributes, useLazy, useMounted, useNullableActivity, @@ -11,6 +9,8 @@ import { useStyleEffectSwipeBack, useZIndexBase, } from "@stackflow/react-ui-core"; +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import { createContext, useContext, useMemo, useRef } from "react"; import { useGlobalOptions } from "../basicUIPlugin"; import type { GlobalVars } from "../basicUIPlugin.css"; import { globalVars } from "../basicUIPlugin.css"; @@ -60,6 +60,7 @@ const AppScreen: React.FC = ({ }) => { const globalOptions = useGlobalOptions(); const activity = useNullableActivity(); + const activityDataAttributes = useActivityDataAttributes(); const mounted = useMounted(); const { pop } = useActions(); @@ -146,6 +147,7 @@ const AppScreen: React.FC = ({ dimRef, edgeRef, paperRef, + appBarRef, offset: OFFSET_PX_CUPERTINO, transitionDuration: globalVars.transitionDuration, preventSwipeBack: @@ -173,8 +175,10 @@ const AppScreen: React.FC = ({ return null; }, - onSwiped() { - pop(); + onSwipeEnd({ swiped }) { + if (swiped) { + pop(); + } }, }); @@ -236,13 +240,15 @@ const AppScreen: React.FC = ({ }), )} data-stackflow-component-name="AppScreen" - data-stackflow-activity-id={mounted ? activity?.id : undefined} - data-stackflow-activity-is-active={ - mounted ? activity?.isActive : undefined - } + {...activityDataAttributes} > {activityEnterStyle !== "slideInLeft" && ( -
+
)} {appBar && ( = ({ )}
{children}
{!activity?.isRoot && globalOptions.theme === "cupertino" && !isSwipeBackPrevented && ( -
+
)}
diff --git a/extensions/react-ui-core/src/index.ts b/extensions/react-ui-core/src/index.ts index 3482cc0b..3de7be0e 100644 --- a/extensions/react-ui-core/src/index.ts +++ b/extensions/react-ui-core/src/index.ts @@ -7,3 +7,4 @@ export * from "./useStyleEffectHide"; export * from "./useStyleEffectOffset"; export * from "./useStyleEffectSwipeBack"; export * from "./useZIndexBase"; +export * from "./useActivityDataAttributes"; diff --git a/extensions/react-ui-core/src/useActivityDataAttributes.ts b/extensions/react-ui-core/src/useActivityDataAttributes.ts new file mode 100644 index 00000000..ed2c6c8c --- /dev/null +++ b/extensions/react-ui-core/src/useActivityDataAttributes.ts @@ -0,0 +1,20 @@ +import { useMounted } from "./useMounted"; +import { useNullableActivity } from "./useNullableActivity"; + +export function useActivityDataAttributes() { + const activity = useNullableActivity(); + const mounted = useMounted(); + + return { + /** + * should be rendered in client-side only to avoid hydration mismatch warning + */ + ...(mounted + ? { + "data-stackflow-activity-id": activity?.id, + "data-stackflow-activity-is-active": activity?.isActive, + "data-stackflow-activity-transition-state": activity?.transitionState, + } + : null), + }; +} diff --git a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts index d6928e9a..2bfb2dff 100644 --- a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts +++ b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts @@ -2,24 +2,32 @@ import type { ActivityTransitionState } from "@stackflow/core"; import { useStyleEffect } from "./useStyleEffect"; import { listenOnce, noop } from "./utils"; +export const SWIPE_BACK_RATIO_CSS_VAR_NAME = "--stackflow-swipe-back-ratio"; + export function useStyleEffectSwipeBack({ dimRef, edgeRef, paperRef, + appBarRef, offset, transitionDuration, preventSwipeBack, getActivityTransitionState, - onSwiped, + onSwipeStart, + onSwipeMove, + onSwipeEnd, }: { dimRef: React.RefObject; edgeRef: React.RefObject; paperRef: React.RefObject; + appBarRef?: React.RefObject; offset: number; transitionDuration: string; preventSwipeBack: boolean; getActivityTransitionState: () => ActivityTransitionState | null; - onSwiped?: () => void; + onSwipeStart?: () => void; + onSwipeMove?: (args: { dx: number; ratio: number }) => void; + onSwipeEnd?: (args: { swiped: boolean }) => void; }) { useStyleEffect({ styleName: "swipe-back", @@ -36,6 +44,7 @@ export function useStyleEffectSwipeBack({ const $dim = dimRef.current; const $edge = edgeRef.current; const $paper = paperRef.current; + const $appBarRef = appBarRef?.current; let x0: number | null = null; let t0: number | null = null; @@ -62,27 +71,30 @@ export function useStyleEffectSwipeBack({ let _rAFLock = false; - function movePaper(dx: number) { + function movePaper({ dx, ratio }: { dx: number; ratio: number }) { if (!_rAFLock) { _rAFLock = true; requestAnimationFrame(() => { - const p = dx / $paper.clientWidth; - - $dim.style.opacity = `${1 - p}`; + $dim.style.opacity = `${1 - ratio}`; $dim.style.transition = "0s"; $paper.style.overflowY = "hidden"; $paper.style.transform = `translate3d(${dx}px, 0, 0)`; $paper.style.transition = "0s"; + $appBarRef?.style.setProperty( + SWIPE_BACK_RATIO_CSS_VAR_NAME, + String(ratio), + ); + refs.forEach((ref) => { if (!ref.current) { return; } ref.current.style.transform = `translate3d(${ - -1 * (1 - p) * offset + -1 * (1 - ratio) * offset }px, 0, 0)`; ref.current.style.transition = "0s"; @@ -106,6 +118,8 @@ export function useStyleEffectSwipeBack({ $paper.style.transform = `translateX(${swiped ? "100%" : "0"})`; $paper.style.transition = transitionDuration; + $appBarRef?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME); + refs.forEach((ref) => { if (!ref.current) { return; @@ -192,6 +206,8 @@ export function useStyleEffectSwipeBack({ : undefined, }; }); + + onSwipeStart?.(); }; const onTouchMove = (e: TouchEvent) => { @@ -202,7 +218,11 @@ export function useStyleEffectSwipeBack({ x = e.touches[0].clientX; - movePaper(x - x0); + const dx = x - x0; + const ratio = dx / $paper.clientWidth; + + movePaper({ dx, ratio }); + onSwipeMove?.({ dx, ratio }); }; const onTouchEnd = () => { @@ -215,9 +235,7 @@ export function useStyleEffectSwipeBack({ const v = (x - x0) / (t - t0); const swiped = v > 1 || x / $paper.clientWidth > 0.4; - if (swiped) { - onSwiped?.(); - } + onSwipeEnd?.({ swiped }); Promise.resolve() .then(() => resetPaper({ swiped }))