diff --git a/README.md b/README.md index 54f727f..89234a2 100644 --- a/README.md +++ b/README.md @@ -125,20 +125,121 @@ 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 Components + +**Note:** These components require React 19.2+ experimental/canary versions. They are not available in stable React 19.0.x releases. + +### 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`. + +```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 ] + } + ] +``` + +**Key Features:** +- Preserves component state when hidden (unlike conditional rendering) +- Hides children using CSS `display: none` +- Effects are cleaned up when hidden and re-created when visible +- Useful for tabs, sidebars, or pre-rendering content for faster navigation + +**Props:** +- `mode` — `Visible` or `Hidden` (defaults to `Visible`) +- `children` — Array of JSX elements to show/hide + +### ViewTransition + +The `ViewTransition` component animates DOM elements when they update inside a Transition. Available in `React.Basic.Hooks.ViewTransition`. + +```purs +import React.Basic.Hooks.ViewTransition (viewTransition, AnimationValue(..)) + +mkAnimatedList :: Component Props +mkAnimatedList = do + component "AnimatedList" \{ items } -> React.do + _isPending /\ startTransition <- useTransition + + let handleRemove item = startTransition do + removeItem item + + pure $ R.div_ $ items <#> \item -> + viewTransition + { children: [ renderItem item ] + , enter: Just (ClassName "slide-in") + , exit: Just (ClassName "slide-out") + , update: Just (ClassName "fade") + , share: Nothing + , default: Nothing + , name: Just ("item-" <> item.id) + , onEnter: Nothing + , onExit: Nothing + , onUpdate: Nothing + , onShare: Nothing + } +``` + +**Animation Triggers:** +- `enter` — Animates when `ViewTransition` is inserted +- `exit` — Animates when `ViewTransition` is deleted +- `update` — Animates when DOM mutations occur inside +- `share` — Animates shared element transitions (requires `name` prop) +- `default` — Fallback animation if no matching trigger + +**Shared Element Transitions:** +```purs +-- When the same name transitions between views, React creates a smooth shared animation +viewTransition + { name: Just "hero-image" + , share: Just (ClassName "morph") + , children: [ thumbnail ] + , enter: Nothing, exit: Nothing, update: Nothing, default: Nothing + , onEnter: Nothing, onExit: Nothing, onUpdate: Nothing, onShare: Nothing + } + +-- Later, in a different view with the same name +viewTransition + { name: Just "hero-image" + , share: Just (ClassName "morph") + , children: [ fullImage ] + , enter: Nothing, exit: Nothing, update: Nothing, default: Nothing + , onEnter: Nothing, onExit: Nothing, onUpdate: Nothing, onShare: Nothing + } +``` + +**Animation Values:** +- `ClassName String` — CSS class name to apply +- `AnimationMap (Object String)` — Map transition types to class names + +**Note:** ViewTransition only activates inside a `Transition` (via `startTransition`) + ## Available Hooks ### Core Hooks (React 16.8+) @@ -169,4 +270,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 19+) ``` diff --git a/package-lock.json b/package-lock.json index f4f6ffe..f094f77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "version": "7.16.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/highlight": "^7.16.0" }, @@ -40,6 +41,7 @@ "version": "7.15.7", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -48,6 +50,7 @@ "version": "7.16.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.15.7", "chalk": "^2.0.0", @@ -77,6 +80,7 @@ "version": "27.2.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -92,6 +96,7 @@ "version": "4.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -106,6 +111,7 @@ "version": "4.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -121,6 +127,7 @@ "version": "2.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -131,12 +138,14 @@ "node_modules/@jest/types/node_modules/color-name": { "version": "1.1.4", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jest/types/node_modules/has-flag": { "version": "4.0.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -145,6 +154,7 @@ "version": "7.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -304,17 +314,20 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.3", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -323,6 +336,7 @@ "version": "3.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -330,26 +344,14 @@ "node_modules/@types/node": { "version": "16.11.10", "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 + "license": "MIT", + "peer": true }, "node_modules/@types/yargs": { "version": "16.0.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -357,7 +359,8 @@ "node_modules/@types/yargs-parser": { "version": "20.2.1", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/abab": { "version": "2.0.5", @@ -515,6 +518,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -1252,13 +1256,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", @@ -1378,6 +1375,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -1425,7 +1423,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.10", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/domain-browser": { "version": "1.2.0", @@ -2426,7 +2425,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jsbn": { "version": "0.1.1", @@ -2438,7 +2438,6 @@ "version": "19.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abab": "^2.0.5", "acorn": "^8.5.0", @@ -2688,6 +2687,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3644,7 +3644,8 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/pidtree": { "version": "0.3.1", @@ -3676,6 +3677,7 @@ "version": "27.3.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "^27.2.5", "ansi-regex": "^5.0.1", @@ -3690,6 +3692,7 @@ "version": "5.0.1", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3698,6 +3701,7 @@ "version": "5.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3978,7 +3982,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3988,7 +3991,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -3999,7 +4001,8 @@ "node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/read": { "version": "1.0.7", @@ -5168,17 +5171,20 @@ "@babel/code-frame": { "version": "7.16.0", "dev": true, + "peer": true, "requires": { "@babel/highlight": "^7.16.0" } }, "@babel/helper-validator-identifier": { "version": "7.15.7", - "dev": true + "dev": true, + "peer": true }, "@babel/highlight": { "version": "7.16.0", "dev": true, + "peer": true, "requires": { "@babel/helper-validator-identifier": "^7.15.7", "chalk": "^2.0.0", @@ -5199,6 +5205,7 @@ "@jest/types": { "version": "27.2.5", "dev": true, + "peer": true, "requires": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -5210,6 +5217,7 @@ "ansi-styles": { "version": "4.3.0", "dev": true, + "peer": true, "requires": { "color-convert": "^2.0.1" } @@ -5217,6 +5225,7 @@ "chalk": { "version": "4.1.2", "dev": true, + "peer": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5225,21 +5234,25 @@ "color-convert": { "version": "2.0.1", "dev": true, + "peer": true, "requires": { "color-name": "~1.1.4" } }, "color-name": { "version": "1.1.4", - "dev": true + "dev": true, + "peer": true }, "has-flag": { "version": "4.0.0", - "dev": true + "dev": true, + "peer": true }, "supports-color": { "version": "7.2.0", "dev": true, + "peer": true, "requires": { "has-flag": "^4.0.0" } @@ -5336,15 +5349,18 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "@types/istanbul-lib-coverage": { "version": "2.0.3", - "dev": true + "dev": true, + "peer": true }, "@types/istanbul-lib-report": { "version": "3.0.0", "dev": true, + "peer": true, "requires": { "@types/istanbul-lib-coverage": "*" } @@ -5352,36 +5368,28 @@ "@types/istanbul-reports": { "version": "3.0.1", "dev": true, + "peer": true, "requires": { "@types/istanbul-lib-report": "*" } }, "@types/node": { "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 + "peer": true }, "@types/yargs": { "version": "16.0.4", "dev": true, + "peer": true, "requires": { "@types/yargs-parser": "*" } }, "@types/yargs-parser": { "version": "20.2.1", - "dev": true + "dev": true, + "peer": true }, "abab": { "version": "2.0.5", @@ -5484,6 +5492,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, + "peer": true, "requires": { "dequal": "^2.0.3" } @@ -6072,12 +6081,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", @@ -6160,7 +6163,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true + "dev": true, + "peer": true }, "des.js": { "version": "1.0.1", @@ -6196,7 +6200,8 @@ }, "dom-accessibility-api": { "version": "0.5.10", - "dev": true + "dev": true, + "peer": true }, "domain-browser": { "version": "1.2.0", @@ -6886,7 +6891,8 @@ }, "js-tokens": { "version": "4.0.0", - "dev": true + "dev": true, + "peer": true }, "jsbn": { "version": "0.1.1", @@ -6897,7 +6903,6 @@ "jsdom": { "version": "19.0.0", "dev": true, - "peer": true, "requires": { "abab": "^2.0.5", "acorn": "^8.5.0", @@ -7081,7 +7086,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true + "dev": true, + "peer": true }, "make-fetch-happen": { "version": "9.1.0", @@ -7771,7 +7777,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "dev": true, + "peer": true }, "pidtree": { "version": "0.3.1", @@ -7788,6 +7795,7 @@ "pretty-format": { "version": "27.3.1", "dev": true, + "peer": true, "requires": { "@jest/types": "^27.2.5", "ansi-regex": "^5.0.1", @@ -7797,11 +7805,13 @@ "dependencies": { "ansi-regex": { "version": "5.0.1", - "dev": true + "dev": true, + "peer": true }, "ansi-styles": { "version": "5.2.0", - "dev": true + "dev": true, + "peer": true } } }, @@ -8023,21 +8033,20 @@ "react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", - "peer": true + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==" }, "react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", - "peer": true, "requires": { "scheduler": "^0.25.0" } }, "react-is": { "version": "17.0.2", - "dev": true + "dev": true, + "peer": true }, "read": { "version": "1.0.7", diff --git a/spago.dhall b/spago.dhall index 0f77e9e..0caebb9 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" @@ -29,6 +30,7 @@ You can edit this file as you like. , "type-equality" , "unsafe-coerce" , "unsafe-reference" + , "web-dom" , "web-html" ] , packages = ./packages.dhall 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..b46afa4 --- /dev/null +++ b/src/React/Basic/Hooks/Activity.purs @@ -0,0 +1,37 @@ +module React.Basic.Hooks.Activity + ( activity + , ActivityMode(..) + ) where + +import Prelude +import React.Basic.Hooks (JSX, ReactComponent, element) + +-- | Represents the visibility mode of an Activity boundary +data ActivityMode + = Visible + | Hidden + +instance Show ActivityMode where + show Visible = "visible" + show Hidden = "hidden" + +-- | The Activity component lets you hide and restore the UI and internal +-- | state of its children while preserving their state and DOM. +-- | +-- | When hidden, children are hidden using CSS `display: none`, but their +-- | component state and DOM structure are preserved. Effects are destroyed +-- | when hidden and re-created when made visible again. +-- | +-- | Example: +-- | ```purescript +-- | activity { mode: Hidden, children: [ myComponent ] } +-- | ``` +-- | +-- | Note: Activity is available in React 19.2+ experimental/canary channels +activity :: { mode :: ActivityMode, children :: Array JSX } -> JSX +activity props = element activity_ + { mode: show props.mode + , children: props.children + } + +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..750d227 --- /dev/null +++ b/src/React/Basic/Hooks/ViewTransition.js @@ -0,0 +1,9 @@ +import React from "react"; + +export const viewTransition_ = React.ViewTransition; + +export const mkClassName = (str) => str; + +export const mkAnimationMap = (obj) => obj; + +export const toCallback_ = (fn) => (element, types) => fn(element)(types); diff --git a/src/React/Basic/Hooks/ViewTransition.purs b/src/React/Basic/Hooks/ViewTransition.purs new file mode 100644 index 0000000..8a8234e --- /dev/null +++ b/src/React/Basic/Hooks/ViewTransition.purs @@ -0,0 +1,102 @@ +module React.Basic.Hooks.ViewTransition + ( viewTransition + , ViewTransitionProps + , AnimationValue(..) + ) 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) +import Web.DOM (Element) + +-- | Animation value can be a CSS class name, "none" to disable, or a map of +-- | transition types to class names +data AnimationValue + = ClassName String + | AnimationMap (Object String) + +-- | Props for the ViewTransition component +type ViewTransitionProps = + { children :: Array JSX + , enter :: Maybe AnimationValue + , exit :: Maybe AnimationValue + , update :: Maybe AnimationValue + , share :: Maybe AnimationValue + , default :: Maybe AnimationValue + , name :: Maybe String + , onEnter :: Maybe (Element -> Array String -> Effect Unit) + , onExit :: Maybe (Element -> Array String -> Effect Unit) + , onUpdate :: Maybe (Element -> Array String -> Effect Unit) + , onShare :: Maybe (Element -> Array String -> Effect Unit) + } + +-- | Internal props used for FFI +type ViewTransitionProps_ = + { children :: Array JSX + , enter :: Nullable ViewTransitionAnimationValue_ + , exit :: Nullable ViewTransitionAnimationValue_ + , update :: Nullable ViewTransitionAnimationValue_ + , share :: Nullable ViewTransitionAnimationValue_ + , default :: Nullable ViewTransitionAnimationValue_ + , name :: Nullable String + , onEnter :: Nullable ViewTransitionCallback_ + , onExit :: Nullable ViewTransitionCallback_ + , onUpdate :: Nullable ViewTransitionCallback_ + , onShare :: Nullable ViewTransitionCallback_ + } + +-- | The ViewTransition component animates DOM elements when they update +-- | inside a Transition. +-- | +-- | It uses the browser's View Transition API to create smooth animations +-- | for element enter/exit, updates, and shared element transitions. +-- | +-- | Example: +-- | ```purescript +-- | viewTransition +-- | { children: [ myContent ] +-- | , enter: Just (ClassName "slide-in") +-- | , exit: Just (ClassName "slide-out") +-- | , name: Just "my-element" +-- | , default: Nothing +-- | , update: Nothing +-- | , share: Nothing +-- | , onEnter: Nothing +-- | , onExit: Nothing +-- | , onUpdate: Nothing +-- | , onShare: Nothing +-- | } +-- | ``` +-- | +-- | Note: ViewTransition only activates inside a Transition (via startTransition) +-- | and is available in React 19+ experimental/canary channels +viewTransition :: ViewTransitionProps -> JSX +viewTransition props = element viewTransition_ + { children: props.children + , enter: toNullable $ toAnimationValue_ <$> props.enter + , exit: toNullable $ toAnimationValue_ <$> props.exit + , update: toNullable $ toAnimationValue_ <$> props.update + , share: toNullable $ toAnimationValue_ <$> props.share + , default: toNullable $ toAnimationValue_ <$> props.default + , name: toNullable props.name + , onEnter: toNullable $ toCallback_ <$> props.onEnter + , onExit: toNullable $ toCallback_ <$> props.onExit + , onUpdate: toNullable $ toCallback_ <$> props.onUpdate + , onShare: toNullable $ toCallback_ <$> props.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_ :: (Element -> Array String -> Effect Unit) -> ViewTransitionCallback_ + +foreign import viewTransition_ :: ReactComponent ViewTransitionProps_ diff --git a/test/Spec/ActivitySpec.purs b/test/Spec/ActivitySpec.purs new file mode 100644 index 0000000..06edd5f --- /dev/null +++ b/test/Spec/ActivitySpec.purs @@ -0,0 +1,164 @@ +module Test.Spec.ActivitySpec where + +import Prelude + +import Test.Spec (Spec, describe, pending) + +spec :: Spec Unit +spec = + describe "Activity component (requires React 19.2+ experimental)" do + pending "shows children when mode is Visible" + pending "hides children when mode is Hidden" + pending "toggles visibility and preserves state" + pending "calls Effect cleanup when hidden" + +{- Test implementation for when React 19.2+ experimental is available: + +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 component" do + it "shows children when mode is Visible" \{ visibleActivity } -> do + { findByTestId } <- renderComponent visibleActivity {} + contentElem <- findByTestId "content" + contentElem `textContentShouldEqual` "Visible Content" + + it "hides children when mode is Hidden" \{ hiddenActivity } -> do + _ <- renderComponent hiddenActivity {} + pure unit + + 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" + + it "calls Effect cleanup when hidden" \{ effectActivity } -> do + { findByTestId } <- renderComponent effectActivity {} + toggleButton <- findByTestId "effect-toggle" + statusElem <- findByTestId "effect-status" + + statusElem `textContentShouldEqual` "mounted" + + fireEventClick toggleButton + fireEventClick toggleButton + + statusElem' <- findByTestId "effect-status" + statusElem' `textContentShouldEqual` "mounted" + + 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" ] + } + ] + } + + hiddenActivity <- + reactComponent "HiddenActivityExample" \(_ :: {}) -> Hooks.do + pure $ activity + { mode: Hidden + , children: + [ R.div + { _data: Object.singleton "testid" "content" + , children: [ R.text "Hidden 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 {} ] + } + ] + + effectComponent <- + reactComponent "EffectComponent" \(_ :: {}) -> Hooks.do + status /\ setStatus <- Hooks.useState "mounting" + + Hooks.useEffect unit do + setStatus (const "mounted") + pure $ setStatus (const "unmounted") + + pure $ R.div + { _data: Object.singleton "testid" "effect-status" + , children: [ R.text status ] + } + + effectActivity <- + reactComponent "EffectActivityExample" \(_ :: {}) -> Hooks.do + isVisible /\ setIsVisible <- Hooks.useState true + + pure $ R.div_ + [ R.button + { _data: Object.singleton "testid" "effect-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 effectComponent {} ] + } + ] + + pure { visibleActivity, hiddenActivity, toggleActivity, effectActivity } +-} diff --git a/test/Spec/ViewTransitionSpec.purs b/test/Spec/ViewTransitionSpec.purs new file mode 100644 index 0000000..4da5fbd --- /dev/null +++ b/test/Spec/ViewTransitionSpec.purs @@ -0,0 +1,15 @@ +module Test.Spec.ViewTransitionSpec where + +import Prelude + +import Test.Spec (Spec, describe, pending) + +spec :: Spec Unit +spec = + describe "ViewTransition component (requires React 19.2+ experimental)" do + pending "renders children with enter animation" + pending "renders with exit animation when removed" + pending "handles update animations" + pending "handles shared element transitions with names" + pending "accepts AnimationMap for different transition types" + pending "calls onEnter callback when entering"