diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b48b924 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Disable lockfile generation +package-lock=false diff --git a/index.html b/index.html new file mode 100644 index 0000000..2fca225 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Preact In Motion + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c9d3a2 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "vite-preact-ts-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@preact/signals": "^2", + "motion": "^12", + "preact": "^10", + "preact-in-motion": "*", + "radashi": "^12" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "typescript": "^5.8.3", + "vite": "^7.0.4", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..37fa10a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,5 @@ +import 'preact-in-motion' +import { navigate } from './navigate' +import './styles/main.css' + +navigate(new URL(location.href)) diff --git a/src/navigate.tsx b/src/navigate.tsx new file mode 100644 index 0000000..458a88e --- /dev/null +++ b/src/navigate.tsx @@ -0,0 +1,47 @@ +import { Fragment, render, type ComponentType } from 'preact' + +const pages = import.meta.glob(['./**/index.tsx', './404.tsx'], { + base: './pages/', +}) + +const layouts = import.meta.glob('./**/layout.tsx', { + base: './pages/', +}) + +export function navigate(url: URL) { + const currentPage = url.pathname + .replace(/^\/?/, './') + .replace(/\/?$/, '/index.tsx') + + const getPageModule = (pages[currentPage] ?? pages['404.tsx']) as + | (() => Promise<{ default: ComponentType }>) + | undefined + + let getLayoutModule: typeof getPageModule + for ( + let parts = currentPage.split('/').slice(0, -1); + parts.length > 0; + parts.pop() + ) { + getLayoutModule = layouts[ + parts.join('/') + '/layout.tsx' + ] as typeof getLayoutModule + } + + getLayoutModule ??= async () => ({ + default: Fragment, + }) + + if (getPageModule) { + Promise.all([getPageModule(), getLayoutModule()]).then( + ([{ default: Page }, { default: Layout }]) => { + render( + + + , + document.getElementById('app')! + ) + } + ) + } +} diff --git a/src/pages/animate-presence/index.tsx b/src/pages/animate-presence/index.tsx new file mode 100644 index 0000000..380eb7c --- /dev/null +++ b/src/pages/animate-presence/index.tsx @@ -0,0 +1,3 @@ +export default () => { + return
AnimatePresence
+} diff --git a/src/pages/index.tsx b/src/pages/index.tsx new file mode 100644 index 0000000..b2d9172 --- /dev/null +++ b/src/pages/index.tsx @@ -0,0 +1,136 @@ +import { easeInOut, spring } from 'motion' +import 'preact-in-motion' +import { AnimatePresence } from 'preact-in-motion' +import { useState } from 'preact/hooks' +import { withExamples } from '~/utils/withExamples' +import { withVisibility } from '~/utils/withVisibility' + +const BasicAnimation = ({ visible }: { visible: boolean }) => { + return ( +
+ Basic Animation +
+ ) +} + +// Lifecycle Animations Component +const LifecycleAnimations = () => { + return ( + <> +
Lifecycle Animations
+ + + ) +} + +// AnimatePresence Component +const AnimatePresenceExample = ({ visible }: { visible: boolean }) => { + return ( + + {visible && ( +

+ Fade Out Then Unmount +

+ )} +
+ ) +} + +// AnimatePresence with Multiple Elements Component +const AnimatePresenceMultiple = () => { + const [isHappy, setIsHappy] = useState(true) + + return ( +
+ + {isHappy ? ( + + 😄 I'm happy! + + ) : ( + + 😢 I'm sad... + + )} + + +
+ ) +} + +// Spring Animation Component +const SpringAnimation = () => { + return ( +
+ Spring Animation +
+ ) +} + +// Easing Function Component +const EasingFunction = () => { + return ( +
+ Easing Function Animation +
+ ) +} + +export default withExamples([ + withVisibility(BasicAnimation), + LifecycleAnimations, + withVisibility(AnimatePresenceExample), + AnimatePresenceMultiple, + SpringAnimation, + EasingFunction, +]) diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx new file mode 100644 index 0000000..becb03c --- /dev/null +++ b/src/pages/layout.tsx @@ -0,0 +1,17 @@ +import type { ComponentChildren } from 'preact' + +export default (props: { children: ComponentChildren }) => { + return ( +
+
+

Preact In Motion

+ +
+ {props.children} +
+ ) +} diff --git a/src/pages/options/index.tsx b/src/pages/options/index.tsx new file mode 100644 index 0000000..ef5981b --- /dev/null +++ b/src/pages/options/index.tsx @@ -0,0 +1,22 @@ +import { withExamples } from '~/utils/withExamples' +import { withVisibility } from '~/utils/withVisibility' + +const TransitionOption = ({ visible }: { visible: boolean }) => { + return ( +
+ Transition Option +
+ ) +} + +export default withExamples([ + withVisibility(TransitionOption), // +]) diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..833ad3f --- /dev/null +++ b/src/styles/base.css @@ -0,0 +1,51 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; +} + +h3 { + margin: 0; +} + +main { + padding: 20px; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..d484f6f --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,37 @@ +@import './base.css'; + +.examples { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + gap: 16px; +} + +.example { + justify-content: center; + min-height: 100px; + border-radius: 12px; + background-color: #2a3a45; + padding: 8px 40px; +} + +@media (prefers-color-scheme: light) { + .example { + background-color: #e6f0f5; + } +} + +.column { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.row { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +} diff --git a/src/utils/withExamples.tsx b/src/utils/withExamples.tsx new file mode 100644 index 0000000..362ce3e --- /dev/null +++ b/src/utils/withExamples.tsx @@ -0,0 +1,27 @@ +import type { ComponentType } from 'preact' +import { isArray } from 'radashi' + +export function withExamples( + components: ComponentType[] | Record +) { + return () => ( +
+ {isArray(components) + ? components.map(Component => ( +
+ +
+ )) + : Object.entries(components).map(([title, components]) => ( + <> +

{title}

+ {components.map(Component => ( +
+ +
+ ))} + + ))} +
+ ) +} diff --git a/src/utils/withVisibility.tsx b/src/utils/withVisibility.tsx new file mode 100644 index 0000000..8d7fe7b --- /dev/null +++ b/src/utils/withVisibility.tsx @@ -0,0 +1,18 @@ +import { useSignal } from '@preact/signals' +import type { FunctionalComponent } from 'preact' + +export const withVisibility = + (Component: FunctionalComponent<{ visible: boolean }>) => () => { + const visible = useSignal(true) + return ( +
+ + +
+ ) + } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..113e13c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "paths": { + "~/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f1b5672 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import preact from '@preact/preset-vite' +import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [preact(), tsconfigPaths()], +})