From 649c241df7520e17ab13c30adfd3452b480ecf9b Mon Sep 17 00:00:00 2001 From: Mark Eibes Date: Mon, 2 Feb 2026 14:38:26 +0100 Subject: [PATCH] Add Activity and ViewTransition components Activity (React 19.2 stable): - Type-safe bindings to React.Activity - ActivityMode type (Visible/Hidden) - Real tests against React 19.2 ViewTransition (React canary, experimental): - Bindings to React.unstable_ViewTransition - AnimationValue type, ViewTransitionInstance opaque type - viewTransitionDefaults for ergonomic record updates - Correct callback signatures (cleanup return, instance arg) --- README.md | 55 ++++++++++++- package-lock.json | 85 ++++++-------------- package.json | 4 +- spago.dhall | 1 + src/React/Basic/Hooks/Activity.js | 3 + src/React/Basic/Hooks/Activity.purs | 18 +++++ src/React/Basic/Hooks/ViewTransition.js | 12 +++ src/React/Basic/Hooks/ViewTransition.purs | 97 +++++++++++++++++++++++ test/Spec/ActivitySpec.purs | 91 +++++++++++++++++++++ test/Spec/ViewTransitionSpec.purs | 11 +++ 10 files changed, 310 insertions(+), 67 deletions(-) create mode 100644 src/React/Basic/Hooks/Activity.js create mode 100644 src/React/Basic/Hooks/Activity.purs create mode 100644 src/React/Basic/Hooks/ViewTransition.js create mode 100644 src/React/Basic/Hooks/ViewTransition.purs create mode 100644 test/Spec/ActivitySpec.purs create mode 100644 test/Spec/ViewTransitionSpec.purs diff --git a/README.md b/README.md index 54f727f..178d563 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ If we prefer this API over the existing react-basic API, we may eventually repla - **React 16.8+**: Core hooks (useState, useEffect, useReducer, useRef, useContext, useMemo, useDebugValue, useLayoutEffect) - **React 18+**: useId, useTransition, useDeferredValue, useSyncExternalStore, useInsertionEffect - **React 19+**: useOptimistic, useActionState, useEffectEvent (experimental) +- **React 19.2+**: Activity component +- **React canary**: ViewTransition component (experimental) ## Example @@ -125,20 +127,65 @@ mkComponent :: Component Props mkComponent = do component "Component" \{ url, onSuccess } -> React.do count /\ setCount <- useState 0 - + -- onSuccess can use the latest count without re-running the effect onSuccessEvent <- useEffectEvent \data -> do onSuccess data count - + -- Effect only re-runs when url changes, not when count changes useEffect url do response <- fetchData url onSuccessEvent response pure mempty - + pure $ R.div_ [ ... ] ``` +## React 19.2 Components + +### Activity + +The `Activity` component lets you hide and restore the UI and internal state of its children while preserving their state and DOM. Available in `React.Basic.Hooks.Activity`. Requires React 19.2+. + +```purs +import React.Basic.Hooks.Activity (activity, ActivityMode(..)) + +mkTabPanel :: Component Props +mkTabPanel = do + component "TabPanel" \{ activeTab } -> React.do + pure $ R.div_ + [ activity + { mode: if activeTab == "tab1" then Visible else Hidden + , children: [ tab1Content ] + } + , activity + { mode: if activeTab == "tab2" then Visible else Hidden + , children: [ tab2Content ] + } + ] +``` + +### ViewTransition (Experimental) + +The `ViewTransition` component animates DOM elements when they update inside a Transition. Available in `React.Basic.Hooks.ViewTransition`. Requires React canary — this API is unstable and may change. + +```purs +import React.Basic.Hooks.ViewTransition (viewTransition, viewTransitionDefaults, AnimationValue(..)) + +mkAnimatedList :: Component Props +mkAnimatedList = do + component "AnimatedList" \{ items } -> React.do + _isPending /\ startTransition <- useTransition + + pure $ R.div_ $ items <#> \item -> + viewTransition $ viewTransitionDefaults + { children = [ renderItem item ] + , enter = Just (ClassName "slide-in") + , exit = Just (ClassName "slide-out") + , name = Just ("item-" <> item.id) + } +``` + ## Available Hooks ### Core Hooks (React 16.8+) @@ -169,4 +216,6 @@ mkComponent = do - Custom hooks via `React.Basic.Hooks.Aff` for async effects - `React.Basic.Hooks.Suspense` for Suspense support - `React.Basic.Hooks.ErrorBoundary` for error boundaries +- `React.Basic.Hooks.Activity` for hiding/showing UI while preserving state (React 19.2) +- `React.Basic.Hooks.ViewTransition` for animated transitions (React canary, experimental) ``` diff --git a/package-lock.json b/package-lock.json index f4f6ffe..6a39190 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "react": "19.0.0", - "react-dom": "19.0.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@testing-library/react": "^16.0.0", @@ -332,20 +332,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true, - "optional": true - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true, - "optional": true - }, "node_modules/@types/yargs": { "version": "16.0.4", "dev": true, @@ -1252,13 +1238,6 @@ "dev": true, "license": "MIT" }, - "node_modules/csstype": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true, - "optional": true - }, "node_modules/cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -3974,9 +3953,9 @@ } }, "node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "peer": true, "engines": { @@ -3984,16 +3963,16 @@ } }, "node_modules/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.25.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.0.0" + "react": "^19.2.5" } }, "node_modules/react-is": { @@ -4200,9 +4179,9 @@ } }, "node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -5360,18 +5339,6 @@ "version": "16.11.10", "dev": true }, - "@types/prop-types": { - "version": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true, - "optional": true - }, - "@types/scheduler": { - "version": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true, - "optional": true - }, "@types/yargs": { "version": "16.0.4", "dev": true, @@ -6072,12 +6039,6 @@ } } }, - "csstype": { - "version": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz", - "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==", - "dev": true, - "optional": true - }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -8021,18 +7982,18 @@ } }, "react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "peer": true }, "react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "peer": true, "requires": { - "scheduler": "^0.25.0" + "scheduler": "^0.27.0" } }, "react-is": { @@ -8196,9 +8157,9 @@ } }, "scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" }, "semver": { "version": "5.7.1", diff --git a/package.json b/package.json index 5d62211..4e88433 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "index.js", "module": "true", "dependencies": { - "react": "19.0.0", - "react-dom": "19.0.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@testing-library/react": "^16.0.0", diff --git a/spago.dhall b/spago.dhall index 0f77e9e..32fc3ca 100644 --- a/spago.dhall +++ b/spago.dhall @@ -14,6 +14,7 @@ You can edit this file as you like. , "either" , "exceptions" , "foldable-traversable" + , "foreign-object" , "functions" , "indexed-monad" , "integers" diff --git a/src/React/Basic/Hooks/Activity.js b/src/React/Basic/Hooks/Activity.js new file mode 100644 index 0000000..6ff1c6e --- /dev/null +++ b/src/React/Basic/Hooks/Activity.js @@ -0,0 +1,3 @@ +import React from "react"; + +export const activity_ = React.Activity; diff --git a/src/React/Basic/Hooks/Activity.purs b/src/React/Basic/Hooks/Activity.purs new file mode 100644 index 0000000..d95ad28 --- /dev/null +++ b/src/React/Basic/Hooks/Activity.purs @@ -0,0 +1,18 @@ +module React.Basic.Hooks.Activity + ( activity + , ActivityMode(..) + ) where + +import React.Basic.Hooks (JSX, ReactComponent, element) + +data ActivityMode + = Visible + | Hidden + +activity :: { mode :: ActivityMode, children :: Array JSX } -> JSX +activity props = element activity_ { mode: modeToString props.mode, children: props.children } + where + modeToString Visible = "visible" + modeToString Hidden = "hidden" + +foreign import activity_ :: ReactComponent { mode :: String, children :: Array JSX } diff --git a/src/React/Basic/Hooks/ViewTransition.js b/src/React/Basic/Hooks/ViewTransition.js new file mode 100644 index 0000000..9fa4857 --- /dev/null +++ b/src/React/Basic/Hooks/ViewTransition.js @@ -0,0 +1,12 @@ +import React from "react"; + +export const viewTransition_ = React.unstable_ViewTransition; + +export const mkClassName = (str) => str; + +export const mkAnimationMap = (obj) => obj; + +export const toCallback_ = (fn) => (instance, types) => { + const cleanup = fn(instance)(types)(); + return cleanup; +}; diff --git a/src/React/Basic/Hooks/ViewTransition.purs b/src/React/Basic/Hooks/ViewTransition.purs new file mode 100644 index 0000000..bf301f1 --- /dev/null +++ b/src/React/Basic/Hooks/ViewTransition.purs @@ -0,0 +1,97 @@ +module React.Basic.Hooks.ViewTransition + ( viewTransition + , viewTransitionDefaults + , ViewTransitionProps + , AnimationValue(..) + , ViewTransitionInstance + ) where + +import Prelude +import Data.Maybe (Maybe(..)) +import Data.Nullable (Nullable, toNullable) +import Effect (Effect) +import Foreign.Object (Object) +import React.Basic.Hooks (JSX, ReactComponent, element) + +data AnimationValue + = ClassName String + | AnimationMap (Object String) + +foreign import data ViewTransitionInstance :: Type + +type ViewTransitionProps = + { children :: Array JSX + , enter :: Maybe AnimationValue + , exit :: Maybe AnimationValue + , update :: Maybe AnimationValue + , share :: Maybe AnimationValue + , layout :: Maybe AnimationValue + , fallback :: Maybe AnimationValue + , name :: Maybe String + , onEnter :: Maybe (ViewTransitionInstance -> Array String -> Effect (Effect Unit)) + , onExit :: Maybe (ViewTransitionInstance -> Array String -> Effect (Effect Unit)) + , onUpdate :: Maybe (ViewTransitionInstance -> Array String -> Effect (Effect Unit)) + , onShare :: Maybe (ViewTransitionInstance -> Array String -> Effect (Effect Unit)) + } + +viewTransitionDefaults :: ViewTransitionProps +viewTransitionDefaults = + { children: [] + , enter: Nothing + , exit: Nothing + , update: Nothing + , share: Nothing + , layout: Nothing + , fallback: Nothing + , name: Nothing + , onEnter: Nothing + , onExit: Nothing + , onUpdate: Nothing + , onShare: Nothing + } + +viewTransition :: ViewTransitionProps -> JSX +viewTransition props = element viewTransition_ (toProps_ props) + where + toProps_ p = + { children: p.children + , enter: toNullable $ toAnimationValue_ <$> p.enter + , exit: toNullable $ toAnimationValue_ <$> p.exit + , update: toNullable $ toAnimationValue_ <$> p.update + , share: toNullable $ toAnimationValue_ <$> p.share + , layout: toNullable $ toAnimationValue_ <$> p.layout + , default: toNullable $ toAnimationValue_ <$> p.fallback + , name: toNullable p.name + , onEnter: toNullable $ toCallback_ <$> p.onEnter + , onExit: toNullable $ toCallback_ <$> p.onExit + , onUpdate: toNullable $ toCallback_ <$> p.onUpdate + , onShare: toNullable $ toCallback_ <$> p.onShare + } + +toAnimationValue_ :: AnimationValue -> ViewTransitionAnimationValue_ +toAnimationValue_ (ClassName str) = mkClassName str +toAnimationValue_ (AnimationMap obj) = mkAnimationMap obj + +foreign import data ViewTransitionAnimationValue_ :: Type +foreign import data ViewTransitionCallback_ :: Type + +foreign import mkClassName :: String -> ViewTransitionAnimationValue_ +foreign import mkAnimationMap :: Object String -> ViewTransitionAnimationValue_ +foreign import toCallback_ :: (ViewTransitionInstance -> Array String -> Effect (Effect Unit)) -> ViewTransitionCallback_ + +type ViewTransitionProps_ = + { children :: Array JSX + , enter :: Nullable ViewTransitionAnimationValue_ + , exit :: Nullable ViewTransitionAnimationValue_ + , update :: Nullable ViewTransitionAnimationValue_ + , share :: Nullable ViewTransitionAnimationValue_ + , layout :: Nullable ViewTransitionAnimationValue_ + , default :: Nullable ViewTransitionAnimationValue_ + , name :: Nullable String + , onEnter :: Nullable ViewTransitionCallback_ + , onExit :: Nullable ViewTransitionCallback_ + , onUpdate :: Nullable ViewTransitionCallback_ + , onShare :: Nullable ViewTransitionCallback_ + } + +foreign import viewTransition_ :: ReactComponent ViewTransitionProps_ diff --git a/test/Spec/ActivitySpec.purs b/test/Spec/ActivitySpec.purs new file mode 100644 index 0000000..4bd93ce --- /dev/null +++ b/test/Spec/ActivitySpec.purs @@ -0,0 +1,91 @@ +module Test.Spec.ActivitySpec where + +import Prelude + +import Data.Tuple.Nested ((/\)) +import Effect.Class (liftEffect) +import Foreign.Object as Object +import React.Basic.DOM as R +import React.Basic.Events (handler_) +import React.Basic.Hooks (reactComponent) +import React.Basic.Hooks as Hooks +import React.Basic.Hooks.Activity (ActivityMode(..), activity) +import React.TestingLibrary (cleanup, fireEventClick, renderComponent) +import Test.Spec (Spec, after_, before, describe, it) +import Test.Spec.Assertions.DOM (textContentShouldEqual) + +spec :: Spec Unit +spec = + after_ cleanup do + before setup do + describe "Activity" do + it "shows children when mode is Visible" \{ visibleActivity } -> do + { findByTestId } <- renderComponent visibleActivity {} + contentElem <- findByTestId "content" + contentElem `textContentShouldEqual` "Visible Content" + + it "toggles visibility and preserves state" \{ toggleActivity } -> do + { findByTestId } <- renderComponent toggleActivity {} + counterElem <- findByTestId "counter" + toggleButton <- findByTestId "toggle" + incrementButton <- findByTestId "increment" + + counterElem `textContentShouldEqual` "Count: 0" + + fireEventClick incrementButton + counterElem `textContentShouldEqual` "Count: 1" + + fireEventClick incrementButton + counterElem `textContentShouldEqual` "Count: 2" + + fireEventClick toggleButton + fireEventClick toggleButton + + counterElem' <- findByTestId "counter" + counterElem' `textContentShouldEqual` "Count: 2" + + where + setup = liftEffect do + visibleActivity <- + reactComponent "VisibleActivityExample" \(_ :: {}) -> Hooks.do + pure $ activity + { mode: Visible + , children: + [ R.div + { _data: Object.singleton "testid" "content" + , children: [ R.text "Visible Content" ] + } + ] + } + + counterComponent <- + reactComponent "Counter" \(_ :: {}) -> Hooks.do + count /\ setCount <- Hooks.useState 0 + pure $ R.div_ + [ R.div + { _data: Object.singleton "testid" "counter" + , children: [ R.text $ "Count: " <> show count ] + } + , R.button + { _data: Object.singleton "testid" "increment" + , onClick: handler_ (setCount (_ + 1)) + , children: [ R.text "Increment" ] + } + ] + + toggleActivity <- + reactComponent "ToggleActivityExample" \(_ :: {}) -> Hooks.do + isVisible /\ setIsVisible <- Hooks.useState true + pure $ R.div_ + [ R.button + { _data: Object.singleton "testid" "toggle" + , onClick: handler_ (setIsVisible not) + , children: [ R.text if isVisible then "Hide" else "Show" ] + } + , activity + { mode: if isVisible then Visible else Hidden + , children: [ Hooks.element counterComponent {} ] + } + ] + + pure { visibleActivity, toggleActivity } diff --git a/test/Spec/ViewTransitionSpec.purs b/test/Spec/ViewTransitionSpec.purs new file mode 100644 index 0000000..e341e6b --- /dev/null +++ b/test/Spec/ViewTransitionSpec.purs @@ -0,0 +1,11 @@ +module Test.Spec.ViewTransitionSpec where + +import Prelude + +import Test.Spec (Spec, describe, pending) + +spec :: Spec Unit +spec = + describe "ViewTransition (requires React canary)" do + pending "renders children with enter animation" + pending "supports shared element transitions"