├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── build.ts ├── changelog.md ├── license ├── package-lock.json ├── package.json ├── publish ├── dom │ └── index.d.ts ├── index.d.ts ├── jsx.d.ts ├── package.json ├── state │ └── index.d.ts └── stdlib │ └── index.d.ts ├── readme.md ├── src ├── bundle.ts ├── dom │ ├── h.ts │ ├── index.ts │ ├── nodeAdd.ts │ ├── nodeInsert.ts │ ├── nodeProperty.ts │ ├── nodeRemove.ts │ ├── readme.md │ └── svg.ts ├── index.ts ├── jsx.d.ts ├── state │ ├── index.ts │ └── readme.md └── stdlib │ ├── index.ts │ └── readme.md ├── test.ts ├── test ├── haptic-dom.test.ts ├── haptic-state.test.ts ├── haptic-stdlib.test.ts └── haptic.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | publish 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "sourceType": "module", 6 | "ecmaVersion": 2020, 7 | "ecmaFeatures": { 8 | "jsx": true 9 | } 10 | }, 11 | // Limit TypeScript linting to TS/TSX 12 | // https://github.com/typescript-eslint/typescript-eslint/issues/1928 13 | "overrides": [ 14 | { 15 | "files": [ 16 | "**/*.{ts,tsx}" 17 | ], 18 | "extends": [ 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 21 | ], 22 | "rules": { 23 | "@typescript-eslint/ban-ts-comment": "off", 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "@typescript-eslint/explicit-module-boundary-types": "off", 26 | "@typescript-eslint/no-empty-function": "off", 27 | "@typescript-eslint/no-explicit-any": [ 28 | "error", 29 | { 30 | "fixToUnknown": true, 31 | "ignoreRestArgs": false 32 | } 33 | ], 34 | "@typescript-eslint/no-floating-promises": [ 35 | "error", 36 | { 37 | "ignoreIIFE": true 38 | } 39 | ], 40 | "@typescript-eslint/no-namespace": "off", 41 | "@typescript-eslint/no-unnecessary-condition": [ 42 | "error", 43 | { 44 | "allowConstantLoopConditions": true 45 | } 46 | ], 47 | "@typescript-eslint/no-unnecessary-type-arguments": "error" 48 | }, 49 | "parser": "@typescript-eslint/parser", 50 | "parserOptions": { 51 | "tsconfigRootDir": "./", 52 | "project": "./tsconfig.json" 53 | } 54 | }, 55 | { 56 | "files": [ 57 | "./build.ts", 58 | "./test.ts", 59 | "**/*.test.ts" 60 | ], 61 | "rules": { 62 | "@typescript-eslint/restrict-template-expressions": "off", 63 | "@typescript-eslint/no-non-null-assertion": "off" 64 | } 65 | } 66 | ], 67 | "env": { 68 | "es6": true, 69 | "node": true, 70 | "browser": true 71 | }, 72 | "plugins": [ 73 | "react" 74 | ], 75 | "extends": [ 76 | "eslint:recommended", 77 | "plugin:@typescript-eslint/eslint-recommended" 78 | ], 79 | "settings": { 80 | "react": { 81 | "pragma": "h", 82 | "createClass": "" 83 | } 84 | }, 85 | "rules": { 86 | "arrow-parens": [ 87 | "error", 88 | "always" 89 | ], 90 | "block-spacing": "error", 91 | "comma-dangle": [ 92 | "error", 93 | { 94 | "arrays": "always-multiline", 95 | "exports": "never", 96 | "functions": "never", 97 | "imports": "never", 98 | "objects": "always-multiline" 99 | } 100 | ], 101 | "comma-spacing": "error", 102 | "default-param-last": "error", 103 | "eol-last": [ 104 | "error", 105 | "always" 106 | ], 107 | "eqeqeq": "error", 108 | "indent": [ 109 | "error", 110 | 2, 111 | { 112 | "SwitchCase": 1 113 | } 114 | ], 115 | "key-spacing": [ 116 | "error", 117 | { 118 | "mode": "minimum" 119 | } 120 | ], 121 | "keyword-spacing": "error", 122 | "multiline-ternary": [ 123 | "error", 124 | "always-multiline" 125 | ], 126 | "no-console": "off", 127 | "no-empty": "off", 128 | "no-fallthrough": "error", 129 | "no-implicit-coercion": "error", 130 | "no-invalid-this": "error", 131 | "no-multi-spaces": [ 132 | "error", 133 | { 134 | "exceptions": { 135 | "Property": true, 136 | "TSTypeAnnotation": true 137 | }, 138 | "ignoreEOLComments": true 139 | } 140 | ], 141 | "no-tabs": "error", 142 | "no-trailing-spaces": "error", 143 | // Switch this as needed, otherwise too much noise 144 | "no-unused-vars": [ 145 | "error", 146 | { 147 | "argsIgnorePattern": "^_" 148 | } 149 | ], 150 | "no-useless-concat": "error", 151 | "object-curly-spacing": [ 152 | "error", 153 | "always" 154 | ], 155 | "operator-linebreak": [ 156 | "error", 157 | "before" 158 | ], 159 | "prefer-destructuring": "error", 160 | "prefer-template": "off", 161 | "quote-props": [ 162 | "error", 163 | "consistent-as-needed" 164 | ], 165 | "quotes": [ 166 | "error", 167 | "single" 168 | ], 169 | "react/jsx-closing-bracket-location": [ 170 | "error", 171 | "after-props" 172 | ], 173 | "react/jsx-closing-tag-location": "error", 174 | "react/jsx-curly-brace-presence": [ 175 | "error", 176 | { 177 | "children": "never", 178 | "props": "never" 179 | } 180 | ], 181 | "react/jsx-equals-spacing": "error", 182 | "react/jsx-indent": [ 183 | "error", 184 | 2 185 | ], 186 | "react/jsx-indent-props": [ 187 | "error", 188 | 2 189 | ], 190 | "react/jsx-no-undef": "error", 191 | "react/jsx-tag-spacing": [ 192 | "error", 193 | { 194 | "closingSlash": "never", 195 | "beforeSelfClosing": "never", 196 | "afterOpening": "never", 197 | "beforeClosing": "never" 198 | } 199 | ], 200 | "react/jsx-uses-react": "error", 201 | "react/jsx-uses-vars": "error", 202 | "react/no-adjacent-inline-elements": "error", 203 | "react/react-in-jsx-scope": "error", 204 | "react/self-closing-comp": "error", 205 | "react/void-dom-elements-no-children": "error", 206 | "semi": [ 207 | "error", 208 | "always" 209 | ], 210 | "semi-spacing": "error", 211 | "space-before-blocks": "error", 212 | "space-in-parens": "error", 213 | "space-infix-ops": "error", 214 | "template-curly-spacing": "error", 215 | "template-tag-spacing": "error" 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | publish/* 3 | publish/dom/* 4 | publish/state/* 5 | publish/stdlib/* 6 | !publish/dom/ 7 | !publish/state/ 8 | !publish/stdlib/ 9 | !publish/**/index.d.ts 10 | !publish/jsx.d.ts 11 | !publish/package.json 12 | *.map 13 | .vscode 14 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { gzipSync } from 'fflate'; 5 | 6 | const entryPoints = [ 7 | 'src/dom/index.ts', 8 | 'src/state/index.ts', 9 | 'src/stdlib/index.ts', 10 | 'src/index.ts', 11 | ]; 12 | 13 | // About 100 characters saved this way. Strings of JS code, so numbers. 14 | const define = { 15 | S_RUNNING: '4', 16 | S_SKIP_RUN_QUEUE: '2', 17 | S_NEEDS_RUN: '1', 18 | }; 19 | 20 | // This is explained in ./src/index.ts. Haptic's bundle entrypoint isn't a self 21 | // contained bundle. This is to support unbundled developement workflows that 22 | // are ESM-only. For production, your bundler can re-bundle it. 23 | const external = [ 24 | 'haptic', 25 | 'haptic/dom', 26 | 'haptic/state', 27 | 'haptic/stdlib', 28 | ]; 29 | 30 | const noComment = (a: Uint8Array) => a.subarray(0, -'\n//# sourceMappingURL=index.js.map'.length); 31 | const gzip = (a: Uint8Array) => gzipSync(a, { level: 9 }); 32 | const relName = (filepath: string) => filepath.replace(/.*publish\//, ''); 33 | const pad = (x: unknown, n: number) => String(x).padEnd(n); 34 | 35 | function walk(dir: string, ext: string, matches: string[] = []) { 36 | const files = fs.readdirSync(dir); 37 | for (const filename of files) { 38 | const filepath = path.join(dir, filename); 39 | if (fs.statSync(filepath).isDirectory()) { 40 | walk(filepath, ext, matches); 41 | } else if (path.extname(filename) === ext) { 42 | matches.push(filepath); 43 | } 44 | } 45 | return matches; 46 | } 47 | 48 | // Gather existing sizes to compare to later on 49 | const prevJSFiles = walk('publish', '.js'); 50 | const prevJSSizes: { [k: string]: number } = {}; 51 | prevJSFiles.forEach((filepath) => { 52 | prevJSSizes[relName(filepath)] 53 | = gzip(noComment(fs.readFileSync(filepath))).length; 54 | }); 55 | 56 | esbuild.build({ 57 | entryPoints, 58 | outdir: 'publish', 59 | external, 60 | format: 'esm', 61 | bundle: true, 62 | sourcemap: true, 63 | // This is better than Terser 64 | minify: true, 65 | write: false, 66 | define, 67 | }).then((build) => { 68 | const byExt: { [filepath: string]: esbuild.OutputFile[] } = {}; 69 | for (const outFile of build.outputFiles) { 70 | const x = path.extname(outFile.path); 71 | (byExt[x] || (byExt[x] = [])).push(outFile); 72 | } 73 | // Fix path since esbuild does it wrong based off the Chrome debugger... 74 | // TODO: Many values are _still_ undefined... I'll need to compare to rollup 75 | for (const { text, path: filepath } of byExt['.map']!) { 76 | fs.writeFileSync(filepath, text 77 | .replace(/"\..*?\/(\w+)\.ts"/g, '"./$1.ts"') 78 | ); 79 | } 80 | for (const { contents, path: filepath } of byExt['.js']!) { 81 | const name = relName(filepath); 82 | const min = noComment(contents); 83 | const mingz = gzip(min); 84 | let delta = ''; 85 | if (prevJSSizes[name]) { 86 | const num = mingz.length - prevJSSizes[name]!; 87 | delta = `Δ:${num > 0 ? `+${num}` : num}`; 88 | } 89 | fs.writeFileSync(filepath, contents); 90 | console.log( 91 | `${pad(name, 16)} min:${pad(min.length, 5)} min+gzip:${pad(mingz.length, 4)} ${delta}` 92 | ); 93 | } 94 | }).catch((err) => { 95 | console.error('ESM', err); 96 | process.exit(1); 97 | }); 98 | 99 | // CommonJS for older Node and require() 100 | esbuild.build({ 101 | entryPoints, 102 | outdir: 'publish', 103 | outExtension: { '.js': '.cjs' }, 104 | external, 105 | format: 'cjs', 106 | bundle: true, 107 | sourcemap: true, 108 | minify: true, 109 | define, 110 | }).catch((err) => { 111 | console.error('CJS', err); 112 | process.exit(1); 113 | }); 114 | 115 | // Other files for publishing 116 | fs.copyFileSync('./license', './publish/license'); 117 | fs.copyFileSync('./src/jsx.d.ts', './publish/jsx.d.ts'); 118 | 119 | fs.writeFileSync('./publish/readme.md', 120 | fs.readFileSync('./readme.md', 'utf-8') 121 | .replace( 122 | /.\/src\/(\w+)\/readme.md/g, 123 | 'https://github.com/heyheyhello/haptic/tree/main/src/$1/') 124 | ); 125 | 126 | // Need to tweak package.json on write 127 | fs.writeFileSync('./publish/package.json', 128 | fs.readFileSync('./package.json', 'utf-8') 129 | .replaceAll('./publish/', './') 130 | .replace(/,?\s*"scripts": {.*?}/ms, '') 131 | .replace(/,?\s*"devDependencies": {.*?}/ms, '') 132 | ); 133 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.10.1 4 | 5 | - Fix an issue where properties were being set via `el.setAttribute('xyz', ...)` 6 | rather than `el['xyz'] = ...`. This lead to subtle but unexpected behaviour, 7 | particularly in situations like ``. 8 | 9 | This was an oversight when removing support for `attrs` from the previous 10 | Sinuous implementation: 11 | 12 | - https://github.com/luwes/sinuous/discussions/180 13 | - https://github.com/heyheyhello/haptic/commit/71155e689724f580dde2585145b1d0d5b32922e1 14 | 15 | - The build script now help with preparing the publishing area to make publishes 16 | less stressful. It also shows the delta in min+gz sizes compared to the 17 | previous build and tweaks sourcemaps. 18 | 19 | ``` 20 | > npm run build 21 | dom/index.js min:1928 min+gzip:955 Δ:0 22 | state/index.js min:1916 min+gzip:909 Δ:0 23 | stdlib/index.js min:508 min+gzip:301 Δ:+45 24 | index.js min:155 min+gzip:144 Δ:0 25 | ``` 26 | 27 | ## 0.10.0 28 | 29 | The API is nearly settled and hopefully the next major release will be 1.0.0. 30 | This is an uncomfortably large release. I'll release smaller changes and fixes 31 | more frequently from now own to avoid this large of a changelog. 32 | 33 | - Use more explicit naming for everything (#2 and #8). 34 | - Rename `haptic/h` to `haptic/dom` 35 | - Rename `haptic/v` to `haptic/w` to `haptic/wire` to `haptic/state` 36 | - Rename `haptic/u` to `haptic/util` to `haptic/stdlib` 37 | 38 | - Publish ESM and CJS builds using Node 16+'s `package.json#exports` field. 39 | 40 | - Write documentation and introduction code that has been tested. 41 | 42 | **`haptic/dom`** 43 | 44 | - Rewrite the main `h()` call to have less function calls (no `item()`). 45 | 46 | - Function are serialized to strings in `haptic/h` rather than implicitly being 47 | wrapped in a reactive context. This forces JSX to be more explicit and 48 | requires `api.patch` to search for a specific data type (a wire, by default). 49 | 50 | Passing a signal implicitly will no longer work: 51 | 52 | ```tsx 53 | // Bad 54 | return

Function is serialized to a string: {sig}

55 | // Good 56 | return

Signal is subscribed and its value updates the DOM: {wire(sig)}

57 | ``` 58 | 59 | - Remove support for `attrs`. This was specific to Sinuous. 60 | 61 | - Remove the global event proxy. This was specific to Sinuous. 62 | 63 | - Fix array handling to create and populate fragments correctly (#13). 64 | 65 | - Adopt `svg()` into the `haptic/dom` package instead of `haptic/stdlib`. 66 | 67 | **`haptic/state`** 68 | 69 | - Rename vocals to simply signals; a term the community is familiar with. Rename 70 | reactors to wires (note "wS+wC" and "core" were also used for some time). 71 | 72 | - Signals now accept a "SubToken" `$` to initiate a read-subscribe instead of 73 | the previous `s(...)` wrapper function. Here's an updated example from the 0.6 74 | changelog: 75 | 76 | ```ts 77 | const data = signal({ count: 0, text: '' }); 78 | wire($ => { 79 | console.log("Wire will run when count is updated:", data.count($)); 80 | console.log("Wire doesn't run when text is updated:", data.text()); 81 | })(); 82 | ``` 83 | 84 | This makes the two types of reads look more visually similar and makes it 85 | clearly a read. 86 | 87 | This $ token is actually also a function that makes it easier to unpack 88 | multiple signal values since it's a fairly common operation: 89 | 90 | ```tsx 91 | // This is fine and works... 92 | const [a, b, c] = [sigA($), sigB($), sigC($)] 93 | // This is smaller and has the same type support for TS 94 | const [a, b, c] = $(sigA, sigB, sigC); 95 | ``` 96 | 97 | - Names/IDs for signals and wires are now their function name. The actual JS 98 | function name! This is from a nice `{[k](){}}[k]` hack and allows signals and 99 | wires to appear naturally in console logs and stacktraces. 100 | 101 | - Transactions are now atomic so all wires read the same signal value (#9). 102 | 103 | - Anonymous (unnamed) signals can directly created with `= signal.anon(45);` 104 | 105 | - Add lazy computed signals ✨ (#1 and #14) 106 | 107 | This is Haptic's version of a `computed()` without creating a new data type. 108 | 109 | Instead, these are defined by passing a wire into a signal, which then acts as 110 | the computation engine for the signal, while the signal is responsible for 111 | communicating the value to other wires. 112 | 113 | ```ts 114 | const state = signal({ 115 | count: 45, 116 | countSquared(wire($ => state.count($) ** 2)), 117 | countSquaredPlusFive(wire($ => state.countSquared($) + 5)), 118 | }); 119 | // Note that the computation has never run up to now. They're _lazy_. 120 | 121 | // Calling countSquaredPlusFive will run countSquared, since it's a dependency. 122 | state.countSquaredPlusFive(); // 2030 123 | 124 | // Calling countSquared does _no work_. It's not stale. The value is cached. 125 | state.countSquared(); // 2025 126 | ``` 127 | 128 | - Replace the expensive topological sort (Set to Array) to a ancestor lookup 129 | loop that actually considers all grandchildren, not only direct children. 130 | 131 | - Rework all internal wire states to use a 3-field bitmask (#14). 132 | 133 | - Unpausing is no longer done by calling the wire since it was inconsistent with 134 | how wires worked in all other cases. Wires must always be able to be run 135 | manually without changing state. Now running a paused wire leaves it paused. 136 | Use the new `wireResume()` to unpause (#14). 137 | 138 | - Removed the ability to chain wires into multiple DOM patches. It was dangerous 139 | and lead to unpredictable behaviour with `when()` or computed signals. It's an 140 | accident waiting to happen (#14). 141 | 142 | - Implement a test runner in Zora that supports TS, ESM, and file watching. 143 | 144 | - Change the function signature for `when()` to accept a `$ => *` function 145 | instead of a wire. Instead, a wire is created internally. 146 | 147 | ```diff 148 | -when(wire($ => { 149 | +when($ => { 150 | const c = data.count($); 151 | return c <= 0 ? '-0' : c <= 10 ? '1..10' : '+10' 152 | -}), { 153 | +}, { 154 | '-0' : () =>

There's no items

155 | '1..10': () =>

There's between 1 and 10 items

156 | '+10' : () =>

There's a lot of items

157 | }); 158 | ``` 159 | 160 | This is because I removed chaining for wires, so `when()` can't simply extend 161 | the given wire, it would need to nest it in a new wire. It makes most sense to 162 | be a computed signal, but typing `when(signal.anon(wire($ =>...` is awful, so 163 | creating a single wire is the best API choice. 164 | 165 | ## 0.8.0 166 | 167 | - Redesign the reactivity engine from scratch based on explicit subscriptions. 168 | 169 | Designed separately in https://github.com/heyheyhello/haptic-reactivity. 170 | 171 | Replaces `haptic/s` as `haptic/v`. 172 | 173 | Introduces _Vocals_ as signals and _Reactors_ as effects. Subscriptions are 174 | explicitly linked by a function `s(...)` created for each reactor run: 175 | 176 | ```ts 177 | const v = vocals({ count: 0, text: '' }); 178 | rx(s => { 179 | console.log("Reactor will run when count is updated:", s(v.count)); 180 | console.log("Reactor doesn't run when text is updated:", v.text()); 181 | })(); 182 | ``` 183 | 184 | Globally unique IDs are used to tag each vocal and reactor. This is useful for 185 | debugging subscriptions. 186 | 187 | Accidental subscriptions are avoided without needing a `sample()` method. 188 | 189 | Reactors must consistently use vocals as either a read-pass or read-subscribe. 190 | Mixing these into the same reactor will throw. 191 | 192 | Reactors tracking nesting (reactors created within a reactor run). 193 | 194 | Reactors use a finite-state-machine to avoid infinite loops, track paused and 195 | stale states, and mark if they have subscriptions after a run. 196 | 197 | Reactors can be paused. This includes all nested reactors. When manually run 198 | to unpause, the reactor only runs if is has been marked as _stale_. 199 | 200 | Reactors are topologically sorted to avoid nested children reactors running 201 | before their parent. This is because reactors clear all children when run, so 202 | these children would otherwise run more times than needed. 203 | 204 | - Add `when()` to conditionally switch DOM content in an efficient way. 205 | 206 | - Replace Sinuous' `api.subscribe` with a generic patch callback. 207 | 208 | ## 0.1.0 - 0.6.0 209 | 210 | - Drop computed signals. They're confusing. 211 | 212 | - List issues with the observer pattern architecture of Haptic and Sinuous. 213 | These will be addressed later. 214 | 215 | - Add `on()`, `transaction()`, and `capture()`. 216 | 217 | ## 0.0.0 218 | 219 | - Rewrite Sinuous in TypeScript. Lifting only `sinuous/h` and 220 | `sinuous/observable` to Haptic as `haptic/h` and `haptic/s`. 221 | 222 | - Include multiple d.ts files which allow for patching other reactive libraries 223 | into the JSX namespace of `haptic/h`. 224 | 225 | - Drop HTM over JSX: https://gitlab.com/nthm/stayknit/-/issues/1 226 | 227 | I love the idea of HTM but it's fragile and no editor plugins provide 228 | comparable autocomplete, formatting, and error checking to JSX. It's too easy 229 | to have silently broken markup in HTM. It's also noticable runtime overhead. 230 | 231 | HTM can be worth it for zero-transpilation workflows, but Haptic already uses 232 | TypeScript. That ship has sailed. Debugging is already supported by sourcemaps 233 | to show readble TS - JSX naturally fits there. 234 | 235 | Haptic needs to approachable to new developers. It's a better developer 236 | experience to use JSX. 237 | 238 | - Design systems for SSR, CSS-in-JS, and Hydration. These are part of the modern 239 | web stack. They will be designed alongside Haptic to complete the picture. 240 | 241 | https://github.com/heyheyhello/stayknit/ 242 | 243 | - Design lifecycle hook support. Supports `onAttach` and `onDetach` hooks 244 | **without** using `MutationObserver`. 245 | 246 | https://www.npmjs.com/package/sinuous-lifecycle 247 | 248 | ## Origin 249 | 250 | - Began researching ideas for designing reactivity in ways that still love the 251 | DOM; without needing a virtual DOM or reconcilation algorithms. 252 | 253 | Notes are at https://gitlab.com/nthm/lovebud 254 | 255 | Discover Sinuous shortly after and contribute there instead. 256 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Gen Hames 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "haptic", 3 | "version": "0.10.1", 4 | "description": "Reactive TSX library in 1.6kb with no compiler, no magic, and no virtual DOM.", 5 | "type": "module", 6 | "main": "./publish/index.js", 7 | "types": "./publish/index.d.ts", 8 | "license": "MIT", 9 | "author": "Gen Hames", 10 | "exports": { 11 | ".": { 12 | "import": "./publish/index.js", 13 | "require": "./publish/index.cjs" 14 | }, 15 | "./dom": { 16 | "import": "./publish/dom/index.js", 17 | "require": "./publish/dom/index.cjs" 18 | }, 19 | "./state": { 20 | "import": "./publish/state/index.js", 21 | "require": "./publish/state/index.cjs" 22 | }, 23 | "./stdlib": { 24 | "import": "./publish/stdlib/index.js", 25 | "require": "./publish/stdlib/index.cjs" 26 | } 27 | }, 28 | "keywords": [ 29 | "reactive", 30 | "dom", 31 | "tsx", 32 | "frontend", 33 | "framework" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/heyheyhello/haptic" 38 | }, 39 | "scripts": { 40 | "build": "node --no-warnings --experimental-loader esbuild-node-loader build.ts", 41 | "test": "node --no-warnings --experimental-loader esbuild-node-loader test.ts", 42 | "gen-dts": "tsc --project tsconfig.json", 43 | "bundlesize": "echo $(esbuild --bundle src/bundle.ts --format=esm --minify --define:S_RUNNING=4 --define:S_SKIP_RUN_QUEUE=2 --define:S_NEEDS_RUN=1 | gzip -9 | wc -c) min+gzip bytes" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "^16.4.12", 47 | "@typescript-eslint/eslint-plugin": "^4.29.0", 48 | "@typescript-eslint/parser": "^4.29.0", 49 | "esbuild": "^0.12.18", 50 | "esbuild-node-loader": "^0.1.1", 51 | "eslint": "^7.32.0", 52 | "eslint-plugin-react": "^7.24.0", 53 | "fflate": "^0.7.1", 54 | "typescript": "^4.3.5", 55 | "zora": "^5.0.0", 56 | "zora-reporters": "^1.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /publish/dom/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { GenericEventAttrs, HTMLAttrs, SVGAttrs, HTMLElements, SVGElements } from '../jsx'; 2 | 3 | type El = Element | Node | DocumentFragment; 4 | type Tag = El | Component | [] | string; 5 | type Component = (...args: unknown[]) => El | undefined; 6 | 7 | declare function h(tag: Tag, props?: unknown, ...children: unknown[]): El | undefined; 8 | declare namespace h { 9 | namespace JSX { 10 | type Element = HTMLElement | SVGElement | DocumentFragment; 11 | interface ElementAttributesProperty { 12 | props: unknown; 13 | } 14 | interface ElementChildrenAttribute { 15 | children: unknown; 16 | } 17 | interface IntrinsicAttributes { 18 | children?: never; 19 | } 20 | type DOMAttributes 21 | = GenericEventAttrs 22 | & { children?: unknown }; 23 | type HTMLAttributes 24 | = HTMLAttrs 25 | & DOMAttributes; 26 | type SVGAttributes 27 | = SVGAttrs 28 | & HTMLAttributes; 29 | type IntrinsicElements 30 | = { [El in keyof HTMLElements]: HTMLAttributes } 31 | & { [El in keyof SVGElements]: SVGAttributes }; 32 | } 33 | } 34 | 35 | /** Renders SVGs by setting h() to the SVG namespace */ 36 | declare function svg Node>(closure: T): ReturnType; 37 | 38 | type Frag = { _startMark: Text }; 39 | declare const api: { 40 | /** Hyperscript reviver */ 41 | h: typeof h; 42 | /** Add a node before a reference node or at the end */ 43 | add: (parent: Node, value: unknown, endMark?: Node) => Node | Frag; 44 | /** Insert a node into an existing node */ 45 | insert: (el: Node, value: unknown, endMark?: Node, current?: Node | Frag, startNode?: ChildNode | null) => Node | Frag | undefined; 46 | /** Set attributes and propeties on a node */ 47 | property: (el: Node, value: unknown, name: string | null, isAttr?: boolean, isCss?: boolean) => void; 48 | /** Removes nodes, starting from `startNode` (inclusive) to `endMark` (exclusive) */ 49 | rm: (parent: Node, startNode: ChildNode | null, endMark: Node) => void; 50 | /** DOM patcher. Receives unknown JSX elements and attributes. To mark the DOM 51 | location as reactive, return true. Call patchDOM() anytime to update. */ 52 | patch: (value: unknown, patchDOM?: (value: unknown) => void, el?: Node, attribute?: string) => boolean; 53 | /** Element namespace URL such as SVG or MathML */ 54 | ns?: string; 55 | }; 56 | 57 | export { h, svg, api }; 58 | export type { Component, El, Tag }; 59 | -------------------------------------------------------------------------------- /publish/index.d.ts: -------------------------------------------------------------------------------- 1 | import { api as _api, svg } from './dom/index.js'; 2 | import type { Component, El, Tag } from './dom/index.js'; 3 | import type { Wire } from './state/index.js'; 4 | import type { GenericEventAttrs, HTMLAttrs, SVGAttrs, HTMLElements, SVGElements } from './jsx'; 5 | 6 | type DistributeWire = T extends any ? Wire : never; 7 | 8 | declare function h(tag: Tag, props?: unknown, ...children: unknown[]): El | undefined; 9 | declare namespace h { 10 | namespace JSX { 11 | type MaybeWire = T | DistributeWire; 12 | type AllowWireForProperties = { 13 | [K in keyof T]: MaybeWire; 14 | }; 15 | type Element = HTMLElement | SVGElement | DocumentFragment; 16 | interface ElementAttributesProperty { 17 | props: unknown; 18 | } 19 | interface ElementChildrenAttribute { 20 | children: unknown; 21 | } 22 | interface IntrinsicAttributes { 23 | children?: never; 24 | } 25 | type DOMAttributes 26 | = GenericEventAttrs 27 | & { children?: unknown }; 28 | type HTMLAttributes 29 | = AllowWireForProperties> 30 | & { style?: MaybeWire | { [key: string]: MaybeWire } } 31 | & DOMAttributes; 32 | type SVGAttributes 33 | = AllowWireForProperties 34 | & HTMLAttributes; 35 | type IntrinsicElements 36 | = { [El in keyof HTMLElements]: HTMLAttributes } 37 | & { [El in keyof SVGElements]: SVGAttributes }; 38 | } 39 | } 40 | 41 | // Swap out h to have the correct JSX namespace 42 | declare const api: Omit & { h: typeof h }; 43 | 44 | export { h, svg, api }; 45 | export type { Component, El, Tag }; 46 | -------------------------------------------------------------------------------- /publish/jsx.d.ts: -------------------------------------------------------------------------------- 1 | // This file contains building blocks to help construct a JSX namespace 2 | export declare type HTMLElements = { 3 | a: HTMLAnchorElement; 4 | abbr: HTMLElement; 5 | address: HTMLElement; 6 | area: HTMLAreaElement; 7 | article: HTMLElement; 8 | aside: HTMLElement; 9 | audio: HTMLAudioElement; 10 | b: HTMLElement; 11 | base: HTMLBaseElement; 12 | bdi: HTMLElement; 13 | bdo: HTMLElement; 14 | big: HTMLElement; 15 | blockquote: HTMLQuoteElement; 16 | body: HTMLBodyElement; 17 | br: HTMLBRElement; 18 | button: HTMLButtonElement; 19 | canvas: HTMLCanvasElement; 20 | caption: HTMLTableCaptionElement; 21 | cite: HTMLElement; 22 | code: HTMLElement; 23 | col: HTMLTableColElement; 24 | colgroup: HTMLTableColElement; 25 | data: HTMLDataElement; 26 | datalist: HTMLDataListElement; 27 | dd: HTMLElement; 28 | del: HTMLModElement; 29 | details: HTMLDetailsElement; 30 | dfn: HTMLElement; 31 | dialog: HTMLDialogElement; 32 | div: HTMLDivElement; 33 | dl: HTMLDListElement; 34 | dt: HTMLElement; 35 | em: HTMLElement; 36 | embed: HTMLEmbedElement; 37 | fieldset: HTMLFieldSetElement; 38 | figcaption: HTMLElement; 39 | figure: HTMLElement; 40 | footer: HTMLElement; 41 | form: HTMLFormElement; 42 | h1: HTMLHeadingElement; 43 | h2: HTMLHeadingElement; 44 | h3: HTMLHeadingElement; 45 | h4: HTMLHeadingElement; 46 | h5: HTMLHeadingElement; 47 | h6: HTMLHeadingElement; 48 | head: HTMLHeadElement; 49 | header: HTMLElement; 50 | hgroup: HTMLElement; 51 | hr: HTMLHRElement; 52 | html: HTMLHtmlElement; 53 | i: HTMLElement; 54 | iframe: HTMLIFrameElement; 55 | img: HTMLImageElement; 56 | input: HTMLInputElement; 57 | ins: HTMLModElement; 58 | kbd: HTMLElement; 59 | keygen: HTMLUnknownElement; 60 | label: HTMLLabelElement; 61 | legend: HTMLLegendElement; 62 | li: HTMLLIElement; 63 | link: HTMLLinkElement; 64 | main: HTMLElement; 65 | map: HTMLMapElement; 66 | mark: HTMLElement; 67 | menu: HTMLMenuElement; 68 | menuitem: HTMLUnknownElement; 69 | meta: HTMLMetaElement; 70 | meter: HTMLMeterElement; 71 | nav: HTMLElement; 72 | noscript: HTMLElement; 73 | object: HTMLObjectElement; 74 | ol: HTMLOListElement; 75 | optgroup: HTMLOptGroupElement; 76 | option: HTMLOptionElement; 77 | output: HTMLOutputElement; 78 | p: HTMLParagraphElement; 79 | param: HTMLParamElement; 80 | picture: HTMLPictureElement; 81 | pre: HTMLPreElement; 82 | progress: HTMLProgressElement; 83 | q: HTMLQuoteElement; 84 | rp: HTMLElement; 85 | rt: HTMLElement; 86 | ruby: HTMLElement; 87 | s: HTMLElement; 88 | samp: HTMLElement; 89 | script: HTMLScriptElement; 90 | section: HTMLElement; 91 | select: HTMLSelectElement; 92 | slot: HTMLSlotElement; 93 | small: HTMLElement; 94 | source: HTMLSourceElement; 95 | span: HTMLSpanElement; 96 | strong: HTMLElement; 97 | style: HTMLStyleElement; 98 | sub: HTMLElement; 99 | summary: HTMLElement; 100 | sup: HTMLElement; 101 | table: HTMLTableElement; 102 | tbody: HTMLTableSectionElement; 103 | td: HTMLTableCellElement; 104 | textarea: HTMLTextAreaElement; 105 | tfoot: HTMLTableSectionElement; 106 | th: HTMLTableCellElement; 107 | thead: HTMLTableSectionElement; 108 | time: HTMLTimeElement; 109 | title: HTMLTitleElement; 110 | tr: HTMLTableRowElement; 111 | track: HTMLTrackElement; 112 | u: HTMLElement; 113 | ul: HTMLUListElement; 114 | var: HTMLElement; 115 | video: HTMLVideoElement; 116 | wbr: HTMLElement; 117 | }; 118 | 119 | export declare type SVGElements = { 120 | svg: SVGSVGElement; 121 | animate: SVGAnimateElement; 122 | circle: SVGCircleElement; 123 | clipPath: SVGClipPathElement; 124 | defs: SVGDefsElement; 125 | desc: SVGDescElement; 126 | ellipse: SVGEllipseElement; 127 | feBlend: SVGFEBlendElement; 128 | feColorMatrix: SVGFEColorMatrixElement; 129 | feComponentTransfer: SVGFEComponentTransferElement; 130 | feComposite: SVGFECompositeElement; 131 | feConvolveMatrix: SVGFEConvolveMatrixElement; 132 | feDiffuseLighting: SVGFEDiffuseLightingElement; 133 | feDisplacementMap: SVGFEDisplacementMapElement; 134 | feFlood: SVGFEFloodElement; 135 | feGaussianBlur: SVGFEGaussianBlurElement; 136 | feImage: SVGFEImageElement; 137 | feMerge: SVGFEMergeElement; 138 | feMergeNode: SVGFEMergeNodeElement; 139 | feMorphology: SVGFEMorphologyElement; 140 | feOffset: SVGFEOffsetElement; 141 | feSpecularLighting: SVGFESpecularLightingElement; 142 | feTile: SVGFETileElement; 143 | feTurbulence: SVGFETurbulenceElement; 144 | filter: SVGFilterElement; 145 | foreignObject: SVGForeignObjectElement; 146 | g: SVGGElement; 147 | image: SVGImageElement; 148 | line: SVGLineElement; 149 | linearGradient: SVGLinearGradientElement; 150 | marker: SVGMarkerElement; 151 | mask: SVGMaskElement; 152 | path: SVGPathElement; 153 | pattern: SVGPatternElement; 154 | polygon: SVGPolygonElement; 155 | polyline: SVGPolylineElement; 156 | radialGradient: SVGRadialGradientElement; 157 | rect: SVGRectElement; 158 | stop: SVGStopElement; 159 | symbol: SVGSymbolElement; 160 | text: SVGTextElement; 161 | tspan: SVGTSpanElement; 162 | use: SVGUseElement; 163 | }; 164 | 165 | type TargetedEvent 166 | 167 | = Omit 168 | & { readonly currentTarget: Target; }; 169 | 170 | type EventHandler = { (event: E): void; } 171 | 172 | type AnimationEventHandler 173 | = EventHandler>; 174 | type ClipboardEventHandler 175 | = EventHandler>; 176 | type CompositionEventHandler 177 | = EventHandler>; 178 | type DragEventHandler 179 | = EventHandler>; 180 | type FocusEventHandler 181 | = EventHandler>; 182 | type GenericEventHandler 183 | = EventHandler>; 184 | type KeyboardEventHandler 185 | = EventHandler>; 186 | type MouseEventHandler 187 | = EventHandler>; 188 | type PointerEventHandler 189 | = EventHandler>; 190 | type TouchEventHandler 191 | = EventHandler>; 192 | type TransitionEventHandler 193 | = EventHandler>; 194 | type UIEventHandler 195 | = EventHandler>; 196 | type WheelEventHandler 197 | = EventHandler>; 198 | 199 | // Receives an element as Target such as HTMLDivElement 200 | export declare type GenericEventAttrs = { 201 | // Image Events 202 | onLoad?: GenericEventHandler; 203 | onLoadCapture?: GenericEventHandler; 204 | onError?: GenericEventHandler; 205 | onErrorCapture?: GenericEventHandler; 206 | 207 | // Clipboard Events 208 | onCopy?: ClipboardEventHandler; 209 | onCopyCapture?: ClipboardEventHandler; 210 | onCut?: ClipboardEventHandler; 211 | onCutCapture?: ClipboardEventHandler; 212 | onPaste?: ClipboardEventHandler; 213 | onPasteCapture?: ClipboardEventHandler; 214 | 215 | // Composition Events 216 | onCompositionEnd?: CompositionEventHandler; 217 | onCompositionEndCapture?: CompositionEventHandler; 218 | onCompositionStart?: CompositionEventHandler; 219 | onCompositionStartCapture?: CompositionEventHandler; 220 | onCompositionUpdate?: CompositionEventHandler; 221 | onCompositionUpdateCapture?: CompositionEventHandler; 222 | 223 | // Details Events 224 | onToggle?: GenericEventHandler; 225 | 226 | // Focus Events 227 | onFocus?: FocusEventHandler; 228 | onFocusCapture?: FocusEventHandler; 229 | onBlur?: FocusEventHandler; 230 | onBlurCapture?: FocusEventHandler; 231 | 232 | // Form Events 233 | onChange?: GenericEventHandler; 234 | onChangeCapture?: GenericEventHandler; 235 | onInput?: GenericEventHandler; 236 | onInputCapture?: GenericEventHandler; 237 | onSearch?: GenericEventHandler; 238 | onSearchCapture?: GenericEventHandler; 239 | onSubmit?: GenericEventHandler; 240 | onSubmitCapture?: GenericEventHandler; 241 | onInvalid?: GenericEventHandler; 242 | onInvalidCapture?: GenericEventHandler; 243 | 244 | // Keyboard Events 245 | onKeyDown?: KeyboardEventHandler; 246 | onKeyDownCapture?: KeyboardEventHandler; 247 | onKeyPress?: KeyboardEventHandler; 248 | onKeyPressCapture?: KeyboardEventHandler; 249 | onKeyUp?: KeyboardEventHandler; 250 | onKeyUpCapture?: KeyboardEventHandler; 251 | 252 | // Media Events 253 | onAbort?: GenericEventHandler; 254 | onAbortCapture?: GenericEventHandler; 255 | onCanPlay?: GenericEventHandler; 256 | onCanPlayCapture?: GenericEventHandler; 257 | onCanPlayThrough?: GenericEventHandler; 258 | onCanPlayThroughCapture?: GenericEventHandler; 259 | onDurationChange?: GenericEventHandler; 260 | onDurationChangeCapture?: GenericEventHandler; 261 | onEmptied?: GenericEventHandler; 262 | onEmptiedCapture?: GenericEventHandler; 263 | onEncrypted?: GenericEventHandler; 264 | onEncryptedCapture?: GenericEventHandler; 265 | onEnded?: GenericEventHandler; 266 | onEndedCapture?: GenericEventHandler; 267 | onLoadedData?: GenericEventHandler; 268 | onLoadedDataCapture?: GenericEventHandler; 269 | onLoadedMetadata?: GenericEventHandler; 270 | onLoadedMetadataCapture?: GenericEventHandler; 271 | onLoadStart?: GenericEventHandler; 272 | onLoadStartCapture?: GenericEventHandler; 273 | onPause?: GenericEventHandler; 274 | onPauseCapture?: GenericEventHandler; 275 | onPlay?: GenericEventHandler; 276 | onPlayCapture?: GenericEventHandler; 277 | onPlaying?: GenericEventHandler; 278 | onPlayingCapture?: GenericEventHandler; 279 | onProgress?: GenericEventHandler; 280 | onProgressCapture?: GenericEventHandler; 281 | onRateChange?: GenericEventHandler; 282 | onRateChangeCapture?: GenericEventHandler; 283 | onSeeked?: GenericEventHandler; 284 | onSeekedCapture?: GenericEventHandler; 285 | onSeeking?: GenericEventHandler; 286 | onSeekingCapture?: GenericEventHandler; 287 | onStalled?: GenericEventHandler; 288 | onStalledCapture?: GenericEventHandler; 289 | onSuspend?: GenericEventHandler; 290 | onSuspendCapture?: GenericEventHandler; 291 | onTimeUpdate?: GenericEventHandler; 292 | onTimeUpdateCapture?: GenericEventHandler; 293 | onVolumeChange?: GenericEventHandler; 294 | onVolumeChangeCapture?: GenericEventHandler; 295 | onWaiting?: GenericEventHandler; 296 | onWaitingCapture?: GenericEventHandler; 297 | 298 | // MouseEvents 299 | onClick?: MouseEventHandler; 300 | onClickCapture?: MouseEventHandler; 301 | onContextMenu?: MouseEventHandler; 302 | onContextMenuCapture?: MouseEventHandler; 303 | onDblClick?: MouseEventHandler; 304 | onDblClickCapture?: MouseEventHandler; 305 | onDrag?: DragEventHandler; 306 | onDragCapture?: DragEventHandler; 307 | onDragEnd?: DragEventHandler; 308 | onDragEndCapture?: DragEventHandler; 309 | onDragEnter?: DragEventHandler; 310 | onDragEnterCapture?: DragEventHandler; 311 | onDragExit?: DragEventHandler; 312 | onDragExitCapture?: DragEventHandler; 313 | onDragLeave?: DragEventHandler; 314 | onDragLeaveCapture?: DragEventHandler; 315 | onDragOver?: DragEventHandler; 316 | onDragOverCapture?: DragEventHandler; 317 | onDragStart?: DragEventHandler; 318 | onDragStartCapture?: DragEventHandler; 319 | onDrop?: DragEventHandler; 320 | onDropCapture?: DragEventHandler; 321 | onMouseDown?: MouseEventHandler; 322 | onMouseDownCapture?: MouseEventHandler; 323 | onMouseEnter?: MouseEventHandler; 324 | onMouseEnterCapture?: MouseEventHandler; 325 | onMouseLeave?: MouseEventHandler; 326 | onMouseLeaveCapture?: MouseEventHandler; 327 | onMouseMove?: MouseEventHandler; 328 | onMouseMoveCapture?: MouseEventHandler; 329 | onMouseOut?: MouseEventHandler; 330 | onMouseOutCapture?: MouseEventHandler; 331 | onMouseOver?: MouseEventHandler; 332 | onMouseOverCapture?: MouseEventHandler; 333 | onMouseUp?: MouseEventHandler; 334 | onMouseUpCapture?: MouseEventHandler; 335 | 336 | // Selection Events 337 | onSelect?: GenericEventHandler; 338 | onSelectCapture?: GenericEventHandler; 339 | 340 | // Touch Events 341 | onTouchCancel?: TouchEventHandler; 342 | onTouchCancelCapture?: TouchEventHandler; 343 | onTouchEnd?: TouchEventHandler; 344 | onTouchEndCapture?: TouchEventHandler; 345 | onTouchMove?: TouchEventHandler; 346 | onTouchMoveCapture?: TouchEventHandler; 347 | onTouchStart?: TouchEventHandler; 348 | onTouchStartCapture?: TouchEventHandler; 349 | 350 | // Pointer Events 351 | onPointerOver?: PointerEventHandler; 352 | onPointerOverCapture?: PointerEventHandler; 353 | onPointerEnter?: PointerEventHandler; 354 | onPointerEnterCapture?: PointerEventHandler; 355 | onPointerDown?: PointerEventHandler; 356 | onPointerDownCapture?: PointerEventHandler; 357 | onPointerMove?: PointerEventHandler; 358 | onPointerMoveCapture?: PointerEventHandler; 359 | onPointerUp?: PointerEventHandler; 360 | onPointerUpCapture?: PointerEventHandler; 361 | onPointerCancel?: PointerEventHandler; 362 | onPointerCancelCapture?: PointerEventHandler; 363 | onPointerOut?: PointerEventHandler; 364 | onPointerOutCapture?: PointerEventHandler; 365 | onPointerLeave?: PointerEventHandler; 366 | onPointerLeaveCapture?: PointerEventHandler; 367 | onGotPointerCapture?: PointerEventHandler; 368 | onGotPointerCaptureCapture?: PointerEventHandler; 369 | onLostPointerCapture?: PointerEventHandler; 370 | onLostPointerCaptureCapture?: PointerEventHandler; 371 | 372 | // UI Events 373 | onScroll?: UIEventHandler; 374 | onScrollCapture?: UIEventHandler; 375 | 376 | // Wheel Events 377 | onWheel?: WheelEventHandler; 378 | onWheelCapture?: WheelEventHandler; 379 | 380 | // Animation Events 381 | onAnimationStart?: AnimationEventHandler; 382 | onAnimationStartCapture?: AnimationEventHandler; 383 | onAnimationEnd?: AnimationEventHandler; 384 | onAnimationEndCapture?: AnimationEventHandler; 385 | onAnimationIteration?: AnimationEventHandler; 386 | onAnimationIterationCapture?: AnimationEventHandler; 387 | 388 | // Transition Events 389 | onTransitionEnd?: TransitionEventHandler; 390 | onTransitionEndCapture?: TransitionEventHandler; 391 | }; 392 | 393 | // Note: HTML elements will also need GenericEventAttributes 394 | export declare type HTMLAttrs = { 395 | // Standard HTML Attributes 396 | accept?: string; 397 | acceptCharset?: string; 398 | accessKey?: string; 399 | action?: string; 400 | allowFullScreen?: boolean; 401 | allowTransparency?: boolean; 402 | alt?: string; 403 | as?: string; 404 | async?: boolean; 405 | autocomplete?: string; 406 | autoComplete?: string; 407 | autocorrect?: string; 408 | autoCorrect?: string; 409 | autofocus?: boolean; 410 | autoFocus?: boolean; 411 | autoPlay?: boolean; 412 | capture?: boolean; 413 | cellPadding?: number | string; 414 | cellSpacing?: number | string; 415 | charSet?: string; 416 | challenge?: string; 417 | checked?: boolean; 418 | class?: string; 419 | className?: string; 420 | cols?: number; 421 | colSpan?: number; 422 | content?: string; 423 | contentEditable?: boolean; 424 | contextMenu?: string; 425 | controls?: boolean; 426 | controlsList?: string; 427 | coords?: string; 428 | crossOrigin?: string; 429 | data?: string; 430 | dateTime?: string; 431 | default?: boolean; 432 | defer?: boolean; 433 | dir?: 'auto' | 'rtl' | 'ltr'; 434 | disabled?: boolean; 435 | disableRemotePlayback?: boolean; 436 | download?: unknown; 437 | draggable?: boolean; 438 | encType?: string; 439 | form?: string; 440 | formAction?: string; 441 | formEncType?: string; 442 | formMethod?: string; 443 | formNoValidate?: boolean; 444 | formTarget?: string; 445 | frameBorder?: number | string; 446 | headers?: string; 447 | height?: number | string; 448 | hidden?: boolean; 449 | high?: number; 450 | href?: string; 451 | hrefLang?: string; 452 | for?: string; 453 | htmlFor?: string; 454 | httpEquiv?: string; 455 | icon?: string; 456 | id?: string; 457 | inputMode?: string; 458 | integrity?: string; 459 | is?: string; 460 | keyParams?: string; 461 | keyType?: string; 462 | kind?: string; 463 | label?: string; 464 | lang?: string; 465 | list?: string; 466 | loop?: boolean; 467 | low?: number; 468 | manifest?: string; 469 | marginHeight?: number; 470 | marginWidth?: number; 471 | max?: number | string; 472 | maxLength?: number; 473 | media?: string; 474 | mediaGroup?: string; 475 | method?: string; 476 | min?: number | string; 477 | minLength?: number; 478 | multiple?: boolean; 479 | muted?: boolean; 480 | name?: string; 481 | nonce?: string; 482 | noValidate?: boolean; 483 | open?: boolean; 484 | optimum?: number; 485 | pattern?: string; 486 | placeholder?: string; 487 | playsInline?: boolean; 488 | poster?: string; 489 | preload?: string; 490 | radioGroup?: string; 491 | readOnly?: boolean; 492 | rel?: string; 493 | required?: boolean; 494 | role?: string; 495 | rows?: number; 496 | rowSpan?: number; 497 | sandbox?: string; 498 | scope?: string; 499 | scoped?: boolean; 500 | scrolling?: string; 501 | seamless?: boolean; 502 | selected?: boolean; 503 | shape?: string; 504 | size?: number; 505 | sizes?: string; 506 | slot?: string; 507 | span?: number; 508 | spellcheck?: boolean; 509 | src?: string; 510 | srcset?: string; 511 | srcDoc?: string; 512 | srcLang?: string; 513 | srcSet?: string; 514 | start?: number; 515 | step?: number | string; 516 | style?: string | { [key: string]: string | number }; 517 | summary?: string; 518 | tabIndex?: number; 519 | target?: string; 520 | title?: string; 521 | type?: string; 522 | useMap?: string; 523 | value?: string | string[] | number; 524 | volume?: string | number; 525 | width?: number | string; 526 | wmode?: string; 527 | wrap?: string; 528 | 529 | // RDFa Attributes 530 | about?: string; 531 | datatype?: string; 532 | inlist?: unknown; 533 | prefix?: string; 534 | property?: string; 535 | resource?: string; 536 | typeof?: string; 537 | vocab?: string; 538 | 539 | // Microdata Attributes 540 | itemProp?: string; 541 | itemScope?: boolean; 542 | itemType?: string; 543 | itemID?: string; 544 | itemRef?: string; 545 | }; 546 | 547 | // Note: SVG elements will also need HTMLAttributes and GenericEventAttributes 548 | export declare type SVGAttrs = { 549 | accentHeight?: number | string; 550 | accumulate?: 'none' | 'sum'; 551 | additive?: 'replace' | 'sum'; 552 | alignmentBaseline?: 553 | | 'auto' 554 | | 'baseline' 555 | | 'before-edge' 556 | | 'text-before-edge' 557 | | 'middle' 558 | | 'central' 559 | | 'after-edge' 560 | | 'text-after-edge' 561 | | 'ideographic' 562 | | 'alphabetic' 563 | | 'hanging' 564 | | 'mathematical' 565 | | 'inherit'; 566 | allowReorder?: 'no' | 'yes'; 567 | alphabetic?: number | string; 568 | amplitude?: number | string; 569 | arabicForm?: 'initial' | 'medial' | 'terminal' | 'isolated'; 570 | ascent?: number | string; 571 | attributeName?: string; 572 | attributeType?: string; 573 | autoReverse?: number | string; 574 | azimuth?: number | string; 575 | baseFrequency?: number | string; 576 | baselineShift?: number | string; 577 | baseProfile?: number | string; 578 | bbox?: number | string; 579 | begin?: number | string; 580 | bias?: number | string; 581 | by?: number | string; 582 | calcMode?: number | string; 583 | capHeight?: number | string; 584 | clip?: number | string; 585 | clipPath?: string; 586 | clipPathUnits?: number | string; 587 | clipRule?: number | string; 588 | colorInterpolation?: number | string; 589 | colorInterpolationFilters?: 'auto' | 'sRGB' | 'linearRGB' | 'inherit'; 590 | colorProfile?: number | string; 591 | colorRendering?: number | string; 592 | contentScriptType?: number | string; 593 | contentStyleType?: number | string; 594 | cursor?: number | string; 595 | cx?: number | string; 596 | cy?: number | string; 597 | d?: string; 598 | decelerate?: number | string; 599 | descent?: number | string; 600 | diffuseConstant?: number | string; 601 | direction?: number | string; 602 | display?: number | string; 603 | divisor?: number | string; 604 | dominantBaseline?: number | string; 605 | dur?: number | string; 606 | dx?: number | string; 607 | dy?: number | string; 608 | edgeMode?: number | string; 609 | elevation?: number | string; 610 | enableBackground?: number | string; 611 | end?: number | string; 612 | exponent?: number | string; 613 | externalResourcesRequired?: number | string; 614 | fill?: string; 615 | fillOpacity?: number | string; 616 | fillRule?: 'nonzero' | 'evenodd' | 'inherit'; 617 | filter?: string; 618 | filterRes?: number | string; 619 | filterUnits?: number | string; 620 | floodColor?: number | string; 621 | floodOpacity?: number | string; 622 | focusable?: number | string; 623 | fontFamily?: string; 624 | fontSize?: number | string; 625 | fontSizeAdjust?: number | string; 626 | fontStretch?: number | string; 627 | fontStyle?: number | string; 628 | fontVariant?: number | string; 629 | fontWeight?: number | string; 630 | format?: number | string; 631 | from?: number | string; 632 | fx?: number | string; 633 | fy?: number | string; 634 | g1?: number | string; 635 | g2?: number | string; 636 | glyphName?: number | string; 637 | glyphOrientationHorizontal?: number | string; 638 | glyphOrientationVertical?: number | string; 639 | glyphRef?: number | string; 640 | gradientTransform?: string; 641 | gradientUnits?: string; 642 | hanging?: number | string; 643 | horizAdvX?: number | string; 644 | horizOriginX?: number | string; 645 | ideographic?: number | string; 646 | imageRendering?: number | string; 647 | in2?: number | string; 648 | in?: string; 649 | intercept?: number | string; 650 | k1?: number | string; 651 | k2?: number | string; 652 | k3?: number | string; 653 | k4?: number | string; 654 | k?: number | string; 655 | kernelMatrix?: number | string; 656 | kernelUnitLength?: number | string; 657 | kerning?: number | string; 658 | keyPoints?: number | string; 659 | keySplines?: number | string; 660 | keyTimes?: number | string; 661 | lengthAdjust?: number | string; 662 | letterSpacing?: number | string; 663 | lightingColor?: number | string; 664 | limitingConeAngle?: number | string; 665 | local?: number | string; 666 | markerEnd?: string; 667 | markerHeight?: number | string; 668 | markerMid?: string; 669 | markerStart?: string; 670 | markerUnits?: number | string; 671 | markerWidth?: number | string; 672 | mask?: string; 673 | maskContentUnits?: number | string; 674 | maskUnits?: number | string; 675 | mathematical?: number | string; 676 | mode?: number | string; 677 | numOctaves?: number | string; 678 | offset?: number | string; 679 | opacity?: number | string; 680 | operator?: number | string; 681 | order?: number | string; 682 | orient?: number | string; 683 | orientation?: number | string; 684 | origin?: number | string; 685 | overflow?: number | string; 686 | overlinePosition?: number | string; 687 | overlineThickness?: number | string; 688 | paintOrder?: number | string; 689 | panose1?: number | string; 690 | pathLength?: number | string; 691 | patternContentUnits?: string; 692 | patternTransform?: number | string; 693 | patternUnits?: string; 694 | pointerEvents?: number | string; 695 | points?: string; 696 | pointsAtX?: number | string; 697 | pointsAtY?: number | string; 698 | pointsAtZ?: number | string; 699 | preserveAlpha?: number | string; 700 | preserveAspectRatio?: string; 701 | primitiveUnits?: number | string; 702 | r?: number | string; 703 | radius?: number | string; 704 | refX?: number | string; 705 | refY?: number | string; 706 | renderingIntent?: number | string; 707 | repeatCount?: number | string; 708 | repeatDur?: number | string; 709 | requiredExtensions?: number | string; 710 | requiredFeatures?: number | string; 711 | restart?: number | string; 712 | result?: string; 713 | rotate?: number | string; 714 | rx?: number | string; 715 | ry?: number | string; 716 | scale?: number | string; 717 | seed?: number | string; 718 | shapeRendering?: number | string; 719 | slope?: number | string; 720 | spacing?: number | string; 721 | specularConstant?: number | string; 722 | specularExponent?: number | string; 723 | speed?: number | string; 724 | spreadMethod?: string; 725 | startOffset?: number | string; 726 | stdDeviation?: number | string; 727 | stemh?: number | string; 728 | stemv?: number | string; 729 | stitchTiles?: number | string; 730 | stopColor?: string; 731 | stopOpacity?: number | string; 732 | strikethroughPosition?: number | string; 733 | strikethroughThickness?: number | string; 734 | string?: number | string; 735 | stroke?: string; 736 | strokeDasharray?: string | number; 737 | strokeDashoffset?: string | number; 738 | strokeLinecap?: 'butt' | 'round' | 'square' | 'inherit'; 739 | strokeLinejoin?: 'miter' | 'round' | 'bevel' | 'inherit'; 740 | strokeMiterlimit?: string; 741 | strokeOpacity?: number | string; 742 | strokeWidth?: number | string; 743 | surfaceScale?: number | string; 744 | systemLanguage?: number | string; 745 | tableValues?: number | string; 746 | targetX?: number | string; 747 | targetY?: number | string; 748 | textAnchor?: string; 749 | textDecoration?: number | string; 750 | textLength?: number | string; 751 | textRendering?: number | string; 752 | to?: number | string; 753 | transform?: string; 754 | u1?: number | string; 755 | u2?: number | string; 756 | underlinePosition?: number | string; 757 | underlineThickness?: number | string; 758 | unicode?: number | string; 759 | unicodeBidi?: number | string; 760 | unicodeRange?: number | string; 761 | unitsPerEm?: number | string; 762 | vAlphabetic?: number | string; 763 | values?: string; 764 | vectorEffect?: number | string; 765 | version?: string; 766 | vertAdvY?: number | string; 767 | vertOriginX?: number | string; 768 | vertOriginY?: number | string; 769 | vHanging?: number | string; 770 | vIdeographic?: number | string; 771 | viewBox?: string; 772 | viewTarget?: number | string; 773 | visibility?: number | string; 774 | vMathematical?: number | string; 775 | widths?: number | string; 776 | wordSpacing?: number | string; 777 | writingMode?: number | string; 778 | x1?: number | string; 779 | x2?: number | string; 780 | x?: number | string; 781 | xChannelSelector?: string; 782 | xHeight?: number | string; 783 | xlinkActuate?: string; 784 | xlinkArcrole?: string; 785 | xlinkHref?: string; 786 | xlinkRole?: string; 787 | xlinkShow?: string; 788 | xlinkTitle?: string; 789 | xlinkType?: string; 790 | xmlBase?: string; 791 | xmlLang?: string; 792 | xmlns?: string; 793 | xmlnsXlink?: string; 794 | xmlSpace?: string; 795 | y1?: number | string; 796 | y2?: number | string; 797 | y?: number | string; 798 | yChannelSelector?: string; 799 | z?: number | string; 800 | zoomAndPan?: string; 801 | }; 802 | -------------------------------------------------------------------------------- /publish/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "haptic", 3 | "version": "0.10.1", 4 | "description": "Reactive TSX library in 1.6kb with no compiler, no magic, and no virtual DOM.", 5 | "type": "module", 6 | "main": "./index.js", 7 | "types": "./index.d.ts", 8 | "license": "MIT", 9 | "author": "Gen Hames", 10 | "exports": { 11 | ".": { 12 | "import": "./index.js", 13 | "require": "./index.cjs" 14 | }, 15 | "./dom": { 16 | "import": "./dom/index.js", 17 | "require": "./dom/index.cjs" 18 | }, 19 | "./state": { 20 | "import": "./state/index.js", 21 | "require": "./state/index.cjs" 22 | }, 23 | "./stdlib": { 24 | "import": "./stdlib/index.js", 25 | "require": "./stdlib/index.cjs" 26 | } 27 | }, 28 | "keywords": [ 29 | "reactive", 30 | "dom", 31 | "tsx", 32 | "frontend", 33 | "framework" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/heyheyhello/haptic" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /publish/state/index.d.ts: -------------------------------------------------------------------------------- 1 | declare type Signal = { 2 | /** Read value */ 3 | (): T; 4 | /** Write value; notifying wires */ 5 | (value: T): void; 6 | /** Read value & subscribe */ 7 | ($: SubToken): T; 8 | /** Wires subscribed to this signal */ 9 | wires: Set>; 10 | /** Transaction value; set and deleted on commit */ 11 | next?: T; 12 | /** If this is a computed-signal, this is its wire */ 13 | cw?: Wire; 14 | /** To check "if x is a signal" */ 15 | $signal: 1; 16 | }; 17 | 18 | declare type Wire = { 19 | /** Run the wire */ 20 | (): T; 21 | /** Signals read-subscribed last run */ 22 | sigRS: Set>; 23 | /** Signals read-passed last run */ 24 | sigRP: Set>; 25 | /** Signals inherited from computed-signals, for consistent two-way linking */ 26 | sigIC: Set>; 27 | /** Post-run tasks */ 28 | tasks: Set<(nextValue: T) => void>; 29 | /** Wire that created this wire (parent of this child) */ 30 | upper: Wire | undefined; 31 | /** Wires created during this run (children of this parent) */ 32 | lower: Set>; 33 | /** FSM state 3-bit bitmask: [RUNNING][SKIP_RUN_QUEUE][NEEDS_RUN] */ 34 | state: WireState; 35 | /** Run count */ 36 | run: number; 37 | /** If part of a computed signal, this is its signal */ 38 | cs?: Signal; 39 | /** To check "if x is a wire" */ 40 | $wire: 1; 41 | }; 42 | 43 | declare type SubToken = { 44 | /** Allow $(...signals) to return an array of read values */ 45 | unknown>>(...args: U): { 46 | [P in keyof U]: U[P] extends Signal ? R : never; 47 | }; 48 | /** Wire to subscribe to */ 49 | wire: Wire; 50 | /** To check "if x is a subscription token" */ 51 | $$: 1; 52 | }; 53 | 54 | /** 3 bits: [RUNNING][SKIP_RUN_QUEUE][NEEDS_RUN] */ 55 | declare type WireStateFields = { 56 | S_RUNNING: 4, 57 | S_SKIP_RUN_QUEUE: 2, 58 | S_NEEDS_RUN: 1, 59 | }; 60 | /** 3 bits: [RUNNING][SKIP_RUN_QUEUE][NEEDS_RUN] */ 61 | declare type WireState = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; 62 | declare type X = any; 63 | 64 | /** 65 | * Void subcription token. Used when a function demands a token but you don't 66 | * want to consent to any signal subscriptions. */ 67 | declare const v$: SubToken; 68 | 69 | /** 70 | * Create a wire. Activate the wire by running it (function call). Any signals 71 | * that read-subscribed during the run will re-run the wire later when written 72 | * to. Wires can be run anytime manually. They're pausable and are resumed when 73 | * called; resuming will avoid a wire run if the wire is not stale. Wires are 74 | * named by their function's name and a counter. */ 75 | declare const createWire: (fn: ($: SubToken) => T) => Wire; 76 | 77 | /** 78 | * Removes two-way subscriptions between its signals and itself. This also turns 79 | * off the wire until it is manually re-run. */ 80 | declare const wireReset: (wire: Wire) => void; 81 | 82 | /** 83 | * Pauses a wire so signal writes won't cause runs. Affects nested wires */ 84 | declare const wirePause: (wire: Wire) => void; 85 | 86 | /** 87 | * Resumes a paused wire. Affects nested wires but skips wires belonging to 88 | * computed-signals. Returns true if any runs were missed during the pause */ 89 | declare const wireResume: (wire: Wire) => boolean; 90 | 91 | /** 92 | * Creates signals for each object entry. Signals are read/write variables which 93 | * hold a list of subscribed wires. When a value is written those wires are 94 | * re-run. Writing a wire into a signal creates a lazy computed-signal. Signals 95 | * are named by the key of the object entry and a global counter. */ 96 | declare const createSignal: { 97 | (obj: T): { [K in keyof T]: Signal ? R : T[K]>; }; 98 | anon: (value: T_1, id?: string) => Signal; 99 | }; 100 | /** 101 | * Batch signal writes so only the last write per signal is applied. Values are 102 | * committed at the end of the function call. */ 103 | declare const transaction: (fn: () => T) => T; 104 | 105 | /** 106 | * Run a function within the context of a wire. Nested children wires are 107 | * adopted (see wire.lower). Also affects signal read consistency checks for 108 | * read-pass (signal.sigRP) and read-subscribe (signal.sigRS). */ 109 | declare const wireAdopt: (wire: Wire | undefined, fn: () => T) => void; 110 | 111 | export { 112 | createSignal as signal, 113 | createWire as wire, 114 | wireReset, 115 | wirePause, 116 | wireResume, 117 | wireAdopt, 118 | transaction, 119 | v$ 120 | }; 121 | export type { Signal, Wire, WireState, WireStateFields, SubToken }; 122 | -------------------------------------------------------------------------------- /publish/stdlib/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Wire, SubToken } from '../state/index.js'; 2 | import type { El } from '../dom/index.js'; 3 | 4 | /** Switches DOM content when signals of the condition wire are written to */ 5 | declare const when: ( 6 | condition: ($: SubToken) => T, 7 | views: { [k in T]?: (() => El) | undefined; } 8 | ) => Wire; 9 | 10 | export { when }; 11 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Haptic 2 | 3 | Reactive web rendering in TSX with no virtual DOM, no compilers, no 4 | dependencies, and no magic. 5 | 6 | It's less than 1600 bytes min+gz. 7 | 8 | ```tsx 9 | import { h } from 'haptic'; 10 | import { signal, wire } from 'haptic/state'; 11 | import { when } from 'haptic/stdlib'; 12 | 13 | const state = signal({ 14 | text: '', 15 | count: 0, 16 | }); 17 | 18 | const Page = () => 19 |
20 |

"{wire(state.text)}"

21 |

You've typed {wire($ => state.text($).length)} characters

22 | state.text(ev.currentTarget.value)} 26 | /> 27 | 30 |

In {wire($ => 5 - state.count($))} clicks the content will change

31 | {when($ => state.count($) > 5 ? "T" : "F", { 32 | T: () => There are over 5 clicks!, 33 | F: () =>

Clicks: {wire(state.count)}

, 34 | })} 35 |
; 36 | 37 | document.body.appendChild(); 38 | ``` 39 | 40 | Haptic is small and explicit because it was born out of JavaScript Fatigue. It 41 | runs in vanilla JS environments and renders using the DOM. Embrace the modern 42 | web; step away from compilers, customs DSLs, and DOM diffing. 43 | 44 | Developers often drown in the over-engineering of their own tools, raising the 45 | barrier to entry for new developers and wasting time. Instead, Haptic focuses on 46 | a modern and reliable developer experience: 47 | 48 | - __Writing in the editor__ leverages TypeScript to provide strong type feedback 49 | and verify code before it's even run. JSDoc comments also supply documentation 50 | when hovering over all exports. 51 | 52 | - __Testing at runtime__ behaves as you'd expect; a div is a div. It's also 53 | nicely debuggable with good error messages and by promoting code styles that 54 | naturally name items in ways that show up in console logs and stacktraces. 55 | It's subtle, but it's especially helpful for reviewing reactive subscriptions. 56 | You'll thank me later. 57 | 58 | - __Optimizing code__ is something you can do by hand. Haptic let's you write 59 | modern reactive web apps and still understand every part of the code. You 60 | don't need to know how Haptic works to use it, but you're in good company if 61 | you ever look under the hood. It's only ~600 lines of well-documented source 62 | code; 340 of which is the single-file reactive state engine. 63 | 64 | ## Install 65 | 66 | ``` 67 | npm install --save haptic 68 | ``` 69 | 70 | Alternatively link directly to the module bundle on Skypack or UNPKG such as 71 | https://unpkg.com/haptic?module for an unbundled ESM script. 72 | 73 | ## Packages 74 | 75 | Haptic is a small collection of packages. This keeps things lightweight and 76 | helps you only import what you'd like. Each package can be used on its own. 77 | 78 | The `haptic` package is simply a wrapper of `haptic/dom` that's configured to 79 | use `haptic/state` for reactivity; it's really only 150 characters minified. 80 | 81 | Rendering is handled in `haptic/dom` and supports any reactive library including 82 | none at all. Reactivity and state is provided by `haptic/state`. Framework 83 | features are part of the standard library in `haptic/stdlib`. 84 | 85 | ### [haptic/dom](./src/dom/readme.md) 86 | 87 | ### [haptic/state](./src/state/readme.md) 88 | 89 | ### [haptic/stdlib](./src/stdlib/readme.md) 90 | 91 | ## Motivation 92 | 93 | Haptic started as a port of Sinuous to TS that used TSX instead of HTML tag 94 | templates. The focus shifted to type safety, debugging, leveraging the editor, 95 | and eventually designing a new reactive state engine from scratch after 96 | influence from Sinuous, Solid, S.js, Reactor.js, and Dipole. 97 | 98 | Hyperscript code is still largely borrowed from Sinuous and Haptic maintains the 99 | same modular API with the new addition of `api.patch`. 100 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | // For testing bundle size with `npm run bundlesize`. This assumes they're using 2 | // the default `api` and won't use `svg()`. 3 | export { h } from './index.js'; 4 | export { signal, wire } from './state/index.js'; 5 | 6 | /* 7 | > esbuild 8 | --bundle src/bundle.ts 9 | --format=esm 10 | --minify 11 | --define:S_RUNNING=4 12 | --define:S_SKIP_RUN_QUEUE=2 13 | --define:S_NEEDS_RUN=1 | gzip -9 | wc -c 14 | */ 15 | -------------------------------------------------------------------------------- /src/dom/h.ts: -------------------------------------------------------------------------------- 1 | import { api } from './index.js'; 2 | 3 | import type { GenericEventAttrs, HTMLAttrs, SVGAttrs, HTMLElements, SVGElements } from '../jsx'; 4 | 5 | type El = Element | Node | DocumentFragment; 6 | type Tag = El | Component | [] | string; 7 | type Component = (...args: unknown[]) => El | undefined; 8 | 9 | function h(tag: Tag, props?: unknown, ...children: unknown[]): El | undefined 10 | function h(tag: Tag, ...args: unknown[]): El | undefined { 11 | if (typeof tag === 'function') { 12 | return tag(...args); 13 | } 14 | let el: El, arg: unknown; 15 | if (typeof tag === 'string') { 16 | el = api.ns 17 | ? document.createElementNS(api.ns, tag) 18 | : document.createElement(tag); 19 | } 20 | else if (Array.isArray(tag)) { 21 | el = document.createDocumentFragment(); 22 | // Using unshift(tag) is -1b gz smaller but is an extra loop iteration 23 | args.unshift(...tag); 24 | } 25 | // Hopefully Element, Node, DocumentFragment, but could be anything... 26 | else { 27 | el = tag; 28 | } 29 | while (args.length) { 30 | arg = args.shift(); 31 | // eslint-disable-next-line eqeqeq 32 | if (arg == null) {} 33 | else if (typeof arg === 'string' || arg instanceof Node) { 34 | // Direct add fast path 35 | api.add(el, arg); 36 | } 37 | else if (Array.isArray(arg)) { 38 | args.unshift(...arg); 39 | } 40 | else if (typeof arg === 'object') { 41 | // eslint-disable-next-line no-implicit-coercion 42 | api.property(el, arg, null, !!api.ns); 43 | } 44 | else if (api.patch(arg)) { 45 | // Last parameter, endMark, is a Text('') node; see nodeAdd.js#Frag 46 | api.insert(el, arg, api.add(el, '') as Text); 47 | } 48 | else { 49 | // Default case, cast as string and add 50 | // eslint-disable-next-line no-implicit-coercion,@typescript-eslint/restrict-plus-operands 51 | api.add(el, '' + arg); 52 | } 53 | } 54 | return el; 55 | } 56 | 57 | export { h }; 58 | export type { Component, El, Tag }; 59 | 60 | // JSX namespace must be bound into a function() next to its definition 61 | declare namespace h { 62 | export namespace JSX { 63 | type Element = HTMLElement | SVGElement | DocumentFragment; 64 | 65 | interface ElementAttributesProperty { props: unknown; } 66 | interface ElementChildrenAttribute { children: unknown; } 67 | 68 | // Prevent children on components that don't declare them 69 | interface IntrinsicAttributes { children?: never; } 70 | 71 | // Allow children on all DOM elements (not components, see above) 72 | // ESLint will error for children on void elements like 73 | type DOMAttributes 74 | = GenericEventAttrs & { children?: unknown }; 75 | 76 | type HTMLAttributes 77 | = HTMLAttrs & DOMAttributes; 78 | 79 | type SVGAttributes 80 | = SVGAttrs & HTMLAttributes; 81 | 82 | type IntrinsicElements = 83 | & { [El in keyof HTMLElements]: HTMLAttributes; } 84 | & { [El in keyof SVGElements]: SVGAttributes; }; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/dom/index.ts: -------------------------------------------------------------------------------- 1 | import { h as _h } from './h.js'; 2 | 3 | import { add } from './nodeAdd.js'; 4 | import { insert } from './nodeInsert.js'; 5 | import { property } from './nodeProperty.js'; 6 | import { remove } from './nodeRemove.js'; 7 | 8 | import { svg } from './svg.js'; 9 | 10 | import type { Component, El, Tag } from './h.js'; 11 | 12 | // This API should be compatible with community libraries that extend Sinuous 13 | const api: { 14 | /** Hyperscript reviver */ 15 | h: typeof _h; 16 | // Customizable internal methods for h() 17 | add: typeof add; 18 | insert: typeof insert; 19 | property: typeof property; 20 | // Renamed for compatibility with Sinuous' community libraries 21 | rm: typeof remove; 22 | /** DOM patcher. Receives unknown JSX elements and attributes. To mark the DOM 23 | location as reactive, return true. Call patchDOM() anytime to update. */ 24 | patch: ( 25 | value: unknown, 26 | // Reactivity could be from Haptic, Sinuous, MobX, Hyperactiv, etc 27 | patchDOM?: (value: unknown) => void, 28 | // Element being patched 29 | el?: Node, 30 | // If this is patching an element property, this is the attribute 31 | attribute?: string 32 | ) => boolean, 33 | /** Element namespace URL such as SVG or MathML */ 34 | ns?: string; 35 | } = { 36 | h: _h, 37 | add, 38 | insert, 39 | property, 40 | rm: remove, 41 | patch: () => false, 42 | }; 43 | 44 | // Reference the latest internal h() allowing others to customize the call 45 | const h: typeof _h = (...args) => api.h(...args); 46 | 47 | export { api, h, svg }; 48 | export type { Component, El, Tag }; 49 | -------------------------------------------------------------------------------- /src/dom/nodeAdd.ts: -------------------------------------------------------------------------------- 1 | import { api } from './index.js'; 2 | 3 | type Value = Node | string | number; 4 | type Frag = { _startMark: Text }; 5 | type FragReturn = Frag | Node | undefined; 6 | 7 | const asNode = (value: unknown): Text | Node | DocumentFragment => { 8 | if (typeof value === 'string') { 9 | return document.createTextNode(value); 10 | } 11 | // Note that a DocumentFragment is an instance of Node 12 | if (!(value instanceof Node)) { 13 | // Passing an empty array creates a DocumentFragment 14 | // Note this means api.add is not purely a subcall of api.h; it can nest 15 | return api.h([], value) as DocumentFragment; 16 | } 17 | return value; 18 | }; 19 | 20 | const maybeFragOrNode = (value: Text | Node | DocumentFragment): FragReturn => { 21 | const { childNodes } = value; 22 | // eslint-disable-next-line eqeqeq 23 | if (value.nodeType != 11 /* DOCUMENT_FRAGMENT_NODE */) return; 24 | if (childNodes.length < 2) return childNodes[0]; 25 | // For a fragment of 2 elements or more add a startMark. This is required for 26 | // multiple nested conditional computeds that return fragments. 27 | 28 | // It looks recursive here but the next call's fragOrNode is only Text('') 29 | return { _startMark: api.add(value, '', childNodes[0]) as Text }; 30 | }; 31 | 32 | /** Add a node before a reference node or at the end. */ 33 | const add = (parent: Node, value: Value | Value[], endMark?: Node) => { 34 | value = asNode(value); 35 | const fragOrNode = maybeFragOrNode(value) || value; 36 | 37 | // If endMark is `null`, value will be added to the end of the list. 38 | parent.insertBefore(value, (endMark && endMark.parentNode && endMark) as Node | null); 39 | return fragOrNode; 40 | }; 41 | 42 | export { add }; 43 | -------------------------------------------------------------------------------- /src/dom/nodeInsert.ts: -------------------------------------------------------------------------------- 1 | import { api } from './index.js'; 2 | 3 | type Frag = { _startMark: Text }; 4 | 5 | /** Insert a node into an existing node. */ 6 | const insert = (el: Node, value: unknown, endMark?: Node, current?: Node | Frag, startNode?: ChildNode | null) => { 7 | // This is needed if the el is a DocumentFragment initially. 8 | el = (endMark && endMark.parentNode) || el; 9 | 10 | // Save startNode of current. In clear() endMark.previousSibling is not always 11 | // accurate if content gets pulled before clearing. 12 | startNode = (startNode || current instanceof Node && current) as ChildNode | null; 13 | 14 | if (value === current) {} 15 | else if ( 16 | (!current || typeof current === 'string') 17 | // @ts-ignore Doesn't like `value += ''` 18 | // eslint-disable-next-line no-implicit-coercion 19 | && (typeof value === 'string' || (typeof value === 'number' && (value += ''))) 20 | ) { 21 | // Block optimized for string insertion 22 | // eslint-disable-next-line eqeqeq 23 | if ((current as unknown) == null || !el.firstChild) { 24 | if (endMark) { 25 | api.add(el, value, endMark); 26 | } else { 27 | // Using textContent is a lot faster than append -> createTextNode 28 | el.textContent = value as string; // Because value += '' 29 | } 30 | } else { 31 | if (endMark) { 32 | // @ts-expect-error Illegal `data` property 33 | (endMark.previousSibling || el.lastChild).data = value; 34 | } else { 35 | // @ts-expect-error Illegal `data` property 36 | el.firstChild.data = value; 37 | } 38 | } 39 | // @ts-expect-error Reusing the variable but doesn't match the signature 40 | current = value; 41 | } 42 | else if ( 43 | api.patch(value, (v) => 44 | current = api.insert(el, v, endMark, current, startNode), el) 45 | ) {} 46 | else { 47 | // Block for Node, Fragment, Array, Functions, etc. This stringifies via h() 48 | if (endMark) { 49 | // `current` can't be `0`, it's coerced to a string in insert. 50 | if (current) { 51 | if (!startNode) { 52 | // Support fragments 53 | startNode = ( 54 | (current as { _startMark?: Text })._startMark 55 | && (current as Frag)._startMark.nextSibling 56 | ) || endMark.previousSibling; 57 | } 58 | api.rm(el, startNode, endMark); 59 | } 60 | } else { 61 | el.textContent = ''; 62 | } 63 | current = value && value !== true 64 | ? api.add(el, value as string | number, endMark) 65 | : undefined; 66 | } 67 | return current; 68 | }; 69 | 70 | export { insert }; 71 | -------------------------------------------------------------------------------- /src/dom/nodeProperty.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | import { api } from './index.js'; 3 | 4 | type EventHandler = (ev: Event) => unknown; 5 | type NodeEvented = Node & { $l?: { [name: string]: EventHandler } }; 6 | 7 | // Note that isAttr is never set. It exists mostly to maintain API compatibility 8 | // with Sinuous and its community packages. However, it's also possible to wrap 9 | // the api.property function and set isAttr from there if needed 10 | 11 | /** Set attributes and propeties on a node. */ 12 | export const property = (el: Node, value: unknown, name: string | null, isAttr?: boolean, isCss?: boolean) => { 13 | if (value == null) {} 14 | else if (!name) { 15 | for (name in value as { [k: string]: unknown }) { 16 | api.property(el, (value as { [k: string]: unknown })[name], name, isAttr, isCss); 17 | } 18 | } 19 | // Functions added as event handlers are not executed on render 20 | // There's only one event listener per type 21 | else if (name[0] == 'o' && name[1] == 'n') { 22 | const listeners = (el as NodeEvented).$l || ((el as NodeEvented).$l = {}); 23 | name = name.slice(2).toLowerCase(); 24 | // Remove the previous function 25 | if (listeners[name]) { 26 | el.removeEventListener(name, listeners[name] as EventHandler); // TS bug 27 | delete listeners[name]; 28 | } 29 | el.addEventListener(name, value as EventHandler); 30 | listeners[name] = value as EventHandler; 31 | } 32 | else if ( 33 | api.patch(value, (v) => api.property(el, v, name, isAttr, isCss), el, name) 34 | ) {} 35 | else if (isCss) { 36 | (el as HTMLElement | SVGElement).style.setProperty(name, value as string); 37 | } 38 | else if ( 39 | isAttr 40 | || name.slice(0, 5) == 'data-' 41 | || name.slice(0, 5) == 'aria-' 42 | ) { 43 | (el as HTMLElement | SVGElement).setAttribute(name, value as string); 44 | } 45 | else if (name == 'style') { 46 | if (typeof value === 'string') { 47 | (el as HTMLElement | SVGElement).style.cssText = value; 48 | } else { 49 | api.property(el, value, null, isAttr, true); 50 | } 51 | } 52 | else { 53 | // Default case; add as a property 54 | // @ts-expect-error 55 | el[name == 'class' ? name + 'Name' : name] = value; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/dom/nodeRemove.ts: -------------------------------------------------------------------------------- 1 | /** Removes nodes from `startNode` (inclusive) to `endMark` (exclusive). */ 2 | const remove = (parent: Node, startNode: ChildNode | null, endMark: Node) => { 3 | while (startNode && startNode !== endMark) { 4 | const n = startNode.nextSibling; 5 | // Is needed in case the child was pulled out the parent before clearing. 6 | if (parent === startNode.parentNode) { 7 | parent.removeChild(startNode); 8 | } 9 | startNode = n; 10 | } 11 | }; 12 | 13 | export { remove }; 14 | -------------------------------------------------------------------------------- /src/dom/readme.md: -------------------------------------------------------------------------------- 1 | # Hyperscript/TSX reviver 2 | 3 | This is a fork of the `sinuous/h` package from Sinuous. It was ported to 4 | TypeScript, simplified in a few places, and now uses a general `api.patch()` 5 | method to support DOM updates with any reactive library. There's no reactivity 6 | baked into `haptic/dom`. Haptic configures reactivity in the primary `haptic` 7 | package where it pairs this reviver with `haptic/state`. 8 | 9 | It's 964 bytes min+gzip on its own. 10 | 11 | Designed to be explicit, type safe, and interoperable with the Sinuous 12 | ecosystem. The Sinuous repository lists some [community packages][1]. 13 | 14 | This `haptic/dom` package exports a vanilla JSX namespace that doesn't expect or 15 | support any reactive functions as elements or attributes. To use a reactive 16 | library of your choice, repeat how the `haptic` package configures `api.patch` 17 | and the JSX namespace for `haptic/state`. 18 | 19 | ## `h(tag: Tag, props?: unknown, ...children: unknown[]): El | undefined` 20 | 21 | ```ts 22 | type El = Element | Node | DocumentFragment; 23 | type Tag = El | Component | [] | string; 24 | type Component = (...args: unknown[]) => El | undefined; 25 | ``` 26 | 27 | The hyperscript reviver. This is really standard. It's how trees of elements are 28 | defined in most frameworks; both JSX and traditional/transpiled `h()`-based 29 | system alike. 30 | 31 | The only notable difference from other frameworks is that functions inlined in 32 | JSX will be serialized to strings. This is a deviation from Sinuous' reviver 33 | which automatically converts any function to a `computed()` (their version of a 34 | computed-signal) in order to support DOM updates. Haptic's reviver explicitly 35 | only targets wires inlined in JSX - any non-wire function is skipped. 36 | 37 | The following two uses of `h()` are equivalent: 38 | 39 | ```tsx 40 | import { h } from 'haptic/dom'; 41 | 42 | document.body.appendChild( 43 | h('div', { style: 'margin-top: 10px;' }, 44 | h('p', 'This is content.') 45 | ) 46 | ); 47 | 48 | document.body.appendChild( 49 |
50 |

This is content

51 |
52 | ); 53 | ``` 54 | 55 | ## `api: { ... }` 56 | 57 | The internal API that connects the functions of the reviver allowing you to 58 | replace or configure them. It was ported from Sinuous, and maintains most of 59 | their API-compatibility, meaning Sinuous community plugins should work. 60 | 61 | Read `./src/dom/index.ts` for the available methods/configurations. 62 | 63 | Typically the usecase is to override methods with wrappers so work can be done 64 | during the render. For example, I wrote a package called _sinuous-lifecycle_ 65 | that provides `onAttach` and `onDetach` lifecycle hooks and works by listening 66 | to API calls to check for components being added or removed. 67 | 68 | Here's a simple example: 69 | 70 | ```tsx 71 | import { api } from 'haptic'; 72 | 73 | const hPrev = api.h; 74 | api.h = (...rest) => { 75 | console.log('api.h:\t', rest); 76 | return hPrev(...rest); 77 | }; 78 | 79 | const addPrev = api.add; 80 | api.add = (...rest) => { 81 | console.log('api.add:\t', rest); 82 | return addPrev(...rest); 83 | }; 84 | 85 |

This is a test...

86 | ``` 87 | 88 | This will log: 89 | 90 | ``` 91 | api.h: ["em", null, "test"] 92 | api.add: [, "test"] 93 | api.h: ["p", null, "This is a", , "..."] 94 | api.add: [

, "This is a"] 95 | api.add: [

, ] 96 | api.add: [

, "..."] 97 | ``` 98 | 99 | ## `svg(closure: () => Node)): El | undefined` 100 | 101 | Tells Haptic's reviver to create SVG namespaced DOM elements for the duration of 102 | the closure. This means it uses `document.createElementNS` instead of the usual 103 | HTML `document.createElement`. Without this, elements like `` would have 104 | very different behaviour. 105 | 106 | Usage: 107 | 108 | ```tsx 109 | import { h, svg } from 'haptic'; // or 'haptic/dom'; 110 | 111 | cosnt <Page> = () => 112 | <p>HTML text with an add icon {svg(() => 113 | <svg viewBox="0 0 15 15" fill="none" width="15" height="15"> 114 | <path d="M7.5 1v13M1 7.5h13" stroke="#000"/> 115 | </svg> 116 | )} inlined in the sentence. 117 | </p>; 118 | 119 | document.body.appendChild(<Page/>); 120 | ``` 121 | 122 | [1]: https://github.com/luwes/sinuous#community 123 | -------------------------------------------------------------------------------- /src/dom/svg.ts: -------------------------------------------------------------------------------- 1 | import { api } from './index.js'; 2 | 3 | /** Renders SVGs by setting h() to the SVG namespace. */ 4 | const svg = <T extends () => Node>(closure: T): ReturnType<T> => { 5 | const prev = api.ns; 6 | api.ns = 'http://www.w3.org/2000/svg'; 7 | const el = closure(); 8 | api.ns = prev; 9 | return el as ReturnType<T>; 10 | }; 11 | 12 | export { svg }; 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Haptic is a bundle of haptic/dom configured to work with haptic/state as the 2 | // reactivity engine. You access haptic/state on its own: 3 | 4 | // import { h } from 'haptic'; 5 | // import { signal, wire } from 'haptic/state'; 6 | 7 | // The 'haptic' package doesn't embed haptic/state in the bundle; code is only 8 | // loaded once despite having two import sites. This should work well for both 9 | // bundlers and unbundled ESM-only/Snowpack/UNPKG workflows. It's important to 10 | // only run one instance of haptic/state because reactivity depends on accessing 11 | // some shared global state that is setup during import. 12 | 13 | // This bundle also extends the JSX namespace to allow using wires in attributes 14 | // and children. Use haptic/dom directly to use a vanilla JSX namespace or to 15 | // extend it yourself for other reactive state libraries. 16 | 17 | // Higher-level features such as control flow, lifecycles, and context are 18 | // available in haptic/stdlib 19 | 20 | import { api, h, svg } from 'haptic/dom'; 21 | 22 | import type { Component, El, Tag } from 'haptic/dom'; 23 | import type { Wire } from 'haptic/state'; 24 | import type { GenericEventAttrs, HTMLAttrs, SVGAttrs, HTMLElements, SVGElements } from './jsx'; 25 | 26 | // When publishing swap out api.h for the correct JSX namespace in index.d.ts 27 | // https://github.com/heyheyhello/haptic/commit/d7cd2819f538c3901ffb0c59e9226fa68d3ae4a9 28 | // declare api = Omit<typeof _api, 'h'> & { h: typeof h }; 29 | 30 | api.patch = (value, patchDOM) => { 31 | // I like type fields that use 1 instead of true/false, so convert via `!!` 32 | // eslint-disable-next-line no-implicit-coercion 33 | const $wire = (value && !!(value as Wire).$wire) as boolean; 34 | if ($wire && patchDOM) { 35 | (value as Wire).tasks.add(patchDOM); 36 | (value as Wire)(); 37 | } 38 | return $wire; 39 | }; 40 | 41 | export { api, h, svg }; 42 | export type { Component, El, Tag }; 43 | 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | type DistributeWire<T> = T extends any ? Wire<T> : never; 46 | 47 | declare namespace h { 48 | export namespace JSX { 49 | type MaybeWire<T> = T | DistributeWire<T>; 50 | type AllowWireForProperties<T> = { [K in keyof T]: MaybeWire<T[K]> }; 51 | 52 | type Element = HTMLElement | SVGElement | DocumentFragment; 53 | 54 | interface ElementAttributesProperty { props: unknown; } 55 | interface ElementChildrenAttribute { children: unknown; } 56 | 57 | // Prevent children on components that don't declare them 58 | interface IntrinsicAttributes { children?: never; } 59 | 60 | // Allow children on all DOM elements (not components, see above) 61 | // ESLint will error for children on void elements like <img/> 62 | type DOMAttributes<Target extends EventTarget> 63 | = GenericEventAttrs<Target> & { children?: unknown }; 64 | 65 | type HTMLAttributes<Target extends EventTarget> 66 | = AllowWireForProperties<Omit<HTMLAttrs, 'style'>> 67 | & { style?: 68 | | MaybeWire<string> 69 | | { [key: string]: MaybeWire<string | number> }; 70 | } 71 | & DOMAttributes<Target>; 72 | 73 | type SVGAttributes<Target extends EventTarget> 74 | = AllowWireForProperties<SVGAttrs> & HTMLAttributes<Target>; 75 | 76 | type IntrinsicElements = 77 | & { [El in keyof HTMLElements]: HTMLAttributes<HTMLElements[El]>; } 78 | & { [El in keyof SVGElements]: SVGAttributes<SVGElements[El]>; }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/jsx.d.ts: -------------------------------------------------------------------------------- 1 | // This file contains building blocks to help construct a JSX namespace 2 | export declare type HTMLElements = { 3 | a: HTMLAnchorElement; 4 | abbr: HTMLElement; 5 | address: HTMLElement; 6 | area: HTMLAreaElement; 7 | article: HTMLElement; 8 | aside: HTMLElement; 9 | audio: HTMLAudioElement; 10 | b: HTMLElement; 11 | base: HTMLBaseElement; 12 | bdi: HTMLElement; 13 | bdo: HTMLElement; 14 | big: HTMLElement; 15 | blockquote: HTMLQuoteElement; 16 | body: HTMLBodyElement; 17 | br: HTMLBRElement; 18 | button: HTMLButtonElement; 19 | canvas: HTMLCanvasElement; 20 | caption: HTMLTableCaptionElement; 21 | cite: HTMLElement; 22 | code: HTMLElement; 23 | col: HTMLTableColElement; 24 | colgroup: HTMLTableColElement; 25 | data: HTMLDataElement; 26 | datalist: HTMLDataListElement; 27 | dd: HTMLElement; 28 | del: HTMLModElement; 29 | details: HTMLDetailsElement; 30 | dfn: HTMLElement; 31 | dialog: HTMLDialogElement; 32 | div: HTMLDivElement; 33 | dl: HTMLDListElement; 34 | dt: HTMLElement; 35 | em: HTMLElement; 36 | embed: HTMLEmbedElement; 37 | fieldset: HTMLFieldSetElement; 38 | figcaption: HTMLElement; 39 | figure: HTMLElement; 40 | footer: HTMLElement; 41 | form: HTMLFormElement; 42 | h1: HTMLHeadingElement; 43 | h2: HTMLHeadingElement; 44 | h3: HTMLHeadingElement; 45 | h4: HTMLHeadingElement; 46 | h5: HTMLHeadingElement; 47 | h6: HTMLHeadingElement; 48 | head: HTMLHeadElement; 49 | header: HTMLElement; 50 | hgroup: HTMLElement; 51 | hr: HTMLHRElement; 52 | html: HTMLHtmlElement; 53 | i: HTMLElement; 54 | iframe: HTMLIFrameElement; 55 | img: HTMLImageElement; 56 | input: HTMLInputElement; 57 | ins: HTMLModElement; 58 | kbd: HTMLElement; 59 | keygen: HTMLUnknownElement; 60 | label: HTMLLabelElement; 61 | legend: HTMLLegendElement; 62 | li: HTMLLIElement; 63 | link: HTMLLinkElement; 64 | main: HTMLElement; 65 | map: HTMLMapElement; 66 | mark: HTMLElement; 67 | menu: HTMLMenuElement; 68 | menuitem: HTMLUnknownElement; 69 | meta: HTMLMetaElement; 70 | meter: HTMLMeterElement; 71 | nav: HTMLElement; 72 | noscript: HTMLElement; 73 | object: HTMLObjectElement; 74 | ol: HTMLOListElement; 75 | optgroup: HTMLOptGroupElement; 76 | option: HTMLOptionElement; 77 | output: HTMLOutputElement; 78 | p: HTMLParagraphElement; 79 | param: HTMLParamElement; 80 | picture: HTMLPictureElement; 81 | pre: HTMLPreElement; 82 | progress: HTMLProgressElement; 83 | q: HTMLQuoteElement; 84 | rp: HTMLElement; 85 | rt: HTMLElement; 86 | ruby: HTMLElement; 87 | s: HTMLElement; 88 | samp: HTMLElement; 89 | script: HTMLScriptElement; 90 | section: HTMLElement; 91 | select: HTMLSelectElement; 92 | slot: HTMLSlotElement; 93 | small: HTMLElement; 94 | source: HTMLSourceElement; 95 | span: HTMLSpanElement; 96 | strong: HTMLElement; 97 | style: HTMLStyleElement; 98 | sub: HTMLElement; 99 | summary: HTMLElement; 100 | sup: HTMLElement; 101 | table: HTMLTableElement; 102 | tbody: HTMLTableSectionElement; 103 | td: HTMLTableCellElement; 104 | textarea: HTMLTextAreaElement; 105 | tfoot: HTMLTableSectionElement; 106 | th: HTMLTableCellElement; 107 | thead: HTMLTableSectionElement; 108 | time: HTMLTimeElement; 109 | title: HTMLTitleElement; 110 | tr: HTMLTableRowElement; 111 | track: HTMLTrackElement; 112 | u: HTMLElement; 113 | ul: HTMLUListElement; 114 | var: HTMLElement; 115 | video: HTMLVideoElement; 116 | wbr: HTMLElement; 117 | }; 118 | 119 | export declare type SVGElements = { 120 | svg: SVGSVGElement; 121 | animate: SVGAnimateElement; 122 | circle: SVGCircleElement; 123 | clipPath: SVGClipPathElement; 124 | defs: SVGDefsElement; 125 | desc: SVGDescElement; 126 | ellipse: SVGEllipseElement; 127 | feBlend: SVGFEBlendElement; 128 | feColorMatrix: SVGFEColorMatrixElement; 129 | feComponentTransfer: SVGFEComponentTransferElement; 130 | feComposite: SVGFECompositeElement; 131 | feConvolveMatrix: SVGFEConvolveMatrixElement; 132 | feDiffuseLighting: SVGFEDiffuseLightingElement; 133 | feDisplacementMap: SVGFEDisplacementMapElement; 134 | feFlood: SVGFEFloodElement; 135 | feGaussianBlur: SVGFEGaussianBlurElement; 136 | feImage: SVGFEImageElement; 137 | feMerge: SVGFEMergeElement; 138 | feMergeNode: SVGFEMergeNodeElement; 139 | feMorphology: SVGFEMorphologyElement; 140 | feOffset: SVGFEOffsetElement; 141 | feSpecularLighting: SVGFESpecularLightingElement; 142 | feTile: SVGFETileElement; 143 | feTurbulence: SVGFETurbulenceElement; 144 | filter: SVGFilterElement; 145 | foreignObject: SVGForeignObjectElement; 146 | g: SVGGElement; 147 | image: SVGImageElement; 148 | line: SVGLineElement; 149 | linearGradient: SVGLinearGradientElement; 150 | marker: SVGMarkerElement; 151 | mask: SVGMaskElement; 152 | path: SVGPathElement; 153 | pattern: SVGPatternElement; 154 | polygon: SVGPolygonElement; 155 | polyline: SVGPolylineElement; 156 | radialGradient: SVGRadialGradientElement; 157 | rect: SVGRectElement; 158 | stop: SVGStopElement; 159 | symbol: SVGSymbolElement; 160 | text: SVGTextElement; 161 | tspan: SVGTSpanElement; 162 | use: SVGUseElement; 163 | }; 164 | 165 | type TargetedEvent 166 | <Target extends EventTarget = EventTarget, TypedEvent extends Event = Event> 167 | = Omit<TypedEvent, 'currentTarget'> 168 | & { readonly currentTarget: Target; }; 169 | 170 | type EventHandler <E extends TargetedEvent> = { (event: E): void; } 171 | 172 | type AnimationEventHandler 173 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, AnimationEvent>>; 174 | type ClipboardEventHandler 175 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, ClipboardEvent>>; 176 | type CompositionEventHandler 177 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, CompositionEvent>>; 178 | type DragEventHandler 179 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, DragEvent>>; 180 | type FocusEventHandler 181 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, FocusEvent>>; 182 | type GenericEventHandler 183 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target>>; 184 | type KeyboardEventHandler 185 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, KeyboardEvent>>; 186 | type MouseEventHandler 187 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, MouseEvent>>; 188 | type PointerEventHandler 189 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, PointerEvent>>; 190 | type TouchEventHandler 191 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, TouchEvent>>; 192 | type TransitionEventHandler 193 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, TransitionEvent>>; 194 | type UIEventHandler 195 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, UIEvent>>; 196 | type WheelEventHandler 197 | <Target extends EventTarget> = EventHandler<TargetedEvent<Target, WheelEvent>>; 198 | 199 | // Receives an element as Target such as HTMLDivElement 200 | export declare type GenericEventAttrs<Target extends EventTarget> = { 201 | // Image Events 202 | onLoad?: GenericEventHandler<Target>; 203 | onLoadCapture?: GenericEventHandler<Target>; 204 | onError?: GenericEventHandler<Target>; 205 | onErrorCapture?: GenericEventHandler<Target>; 206 | 207 | // Clipboard Events 208 | onCopy?: ClipboardEventHandler<Target>; 209 | onCopyCapture?: ClipboardEventHandler<Target>; 210 | onCut?: ClipboardEventHandler<Target>; 211 | onCutCapture?: ClipboardEventHandler<Target>; 212 | onPaste?: ClipboardEventHandler<Target>; 213 | onPasteCapture?: ClipboardEventHandler<Target>; 214 | 215 | // Composition Events 216 | onCompositionEnd?: CompositionEventHandler<Target>; 217 | onCompositionEndCapture?: CompositionEventHandler<Target>; 218 | onCompositionStart?: CompositionEventHandler<Target>; 219 | onCompositionStartCapture?: CompositionEventHandler<Target>; 220 | onCompositionUpdate?: CompositionEventHandler<Target>; 221 | onCompositionUpdateCapture?: CompositionEventHandler<Target>; 222 | 223 | // Details Events 224 | onToggle?: GenericEventHandler<Target>; 225 | 226 | // Focus Events 227 | onFocus?: FocusEventHandler<Target>; 228 | onFocusCapture?: FocusEventHandler<Target>; 229 | onBlur?: FocusEventHandler<Target>; 230 | onBlurCapture?: FocusEventHandler<Target>; 231 | 232 | // Form Events 233 | onChange?: GenericEventHandler<Target>; 234 | onChangeCapture?: GenericEventHandler<Target>; 235 | onInput?: GenericEventHandler<Target>; 236 | onInputCapture?: GenericEventHandler<Target>; 237 | onSearch?: GenericEventHandler<Target>; 238 | onSearchCapture?: GenericEventHandler<Target>; 239 | onSubmit?: GenericEventHandler<Target>; 240 | onSubmitCapture?: GenericEventHandler<Target>; 241 | onInvalid?: GenericEventHandler<Target>; 242 | onInvalidCapture?: GenericEventHandler<Target>; 243 | 244 | // Keyboard Events 245 | onKeyDown?: KeyboardEventHandler<Target>; 246 | onKeyDownCapture?: KeyboardEventHandler<Target>; 247 | onKeyPress?: KeyboardEventHandler<Target>; 248 | onKeyPressCapture?: KeyboardEventHandler<Target>; 249 | onKeyUp?: KeyboardEventHandler<Target>; 250 | onKeyUpCapture?: KeyboardEventHandler<Target>; 251 | 252 | // Media Events 253 | onAbort?: GenericEventHandler<Target>; 254 | onAbortCapture?: GenericEventHandler<Target>; 255 | onCanPlay?: GenericEventHandler<Target>; 256 | onCanPlayCapture?: GenericEventHandler<Target>; 257 | onCanPlayThrough?: GenericEventHandler<Target>; 258 | onCanPlayThroughCapture?: GenericEventHandler<Target>; 259 | onDurationChange?: GenericEventHandler<Target>; 260 | onDurationChangeCapture?: GenericEventHandler<Target>; 261 | onEmptied?: GenericEventHandler<Target>; 262 | onEmptiedCapture?: GenericEventHandler<Target>; 263 | onEncrypted?: GenericEventHandler<Target>; 264 | onEncryptedCapture?: GenericEventHandler<Target>; 265 | onEnded?: GenericEventHandler<Target>; 266 | onEndedCapture?: GenericEventHandler<Target>; 267 | onLoadedData?: GenericEventHandler<Target>; 268 | onLoadedDataCapture?: GenericEventHandler<Target>; 269 | onLoadedMetadata?: GenericEventHandler<Target>; 270 | onLoadedMetadataCapture?: GenericEventHandler<Target>; 271 | onLoadStart?: GenericEventHandler<Target>; 272 | onLoadStartCapture?: GenericEventHandler<Target>; 273 | onPause?: GenericEventHandler<Target>; 274 | onPauseCapture?: GenericEventHandler<Target>; 275 | onPlay?: GenericEventHandler<Target>; 276 | onPlayCapture?: GenericEventHandler<Target>; 277 | onPlaying?: GenericEventHandler<Target>; 278 | onPlayingCapture?: GenericEventHandler<Target>; 279 | onProgress?: GenericEventHandler<Target>; 280 | onProgressCapture?: GenericEventHandler<Target>; 281 | onRateChange?: GenericEventHandler<Target>; 282 | onRateChangeCapture?: GenericEventHandler<Target>; 283 | onSeeked?: GenericEventHandler<Target>; 284 | onSeekedCapture?: GenericEventHandler<Target>; 285 | onSeeking?: GenericEventHandler<Target>; 286 | onSeekingCapture?: GenericEventHandler<Target>; 287 | onStalled?: GenericEventHandler<Target>; 288 | onStalledCapture?: GenericEventHandler<Target>; 289 | onSuspend?: GenericEventHandler<Target>; 290 | onSuspendCapture?: GenericEventHandler<Target>; 291 | onTimeUpdate?: GenericEventHandler<Target>; 292 | onTimeUpdateCapture?: GenericEventHandler<Target>; 293 | onVolumeChange?: GenericEventHandler<Target>; 294 | onVolumeChangeCapture?: GenericEventHandler<Target>; 295 | onWaiting?: GenericEventHandler<Target>; 296 | onWaitingCapture?: GenericEventHandler<Target>; 297 | 298 | // MouseEvents 299 | onClick?: MouseEventHandler<Target>; 300 | onClickCapture?: MouseEventHandler<Target>; 301 | onContextMenu?: MouseEventHandler<Target>; 302 | onContextMenuCapture?: MouseEventHandler<Target>; 303 | onDblClick?: MouseEventHandler<Target>; 304 | onDblClickCapture?: MouseEventHandler<Target>; 305 | onDrag?: DragEventHandler<Target>; 306 | onDragCapture?: DragEventHandler<Target>; 307 | onDragEnd?: DragEventHandler<Target>; 308 | onDragEndCapture?: DragEventHandler<Target>; 309 | onDragEnter?: DragEventHandler<Target>; 310 | onDragEnterCapture?: DragEventHandler<Target>; 311 | onDragExit?: DragEventHandler<Target>; 312 | onDragExitCapture?: DragEventHandler<Target>; 313 | onDragLeave?: DragEventHandler<Target>; 314 | onDragLeaveCapture?: DragEventHandler<Target>; 315 | onDragOver?: DragEventHandler<Target>; 316 | onDragOverCapture?: DragEventHandler<Target>; 317 | onDragStart?: DragEventHandler<Target>; 318 | onDragStartCapture?: DragEventHandler<Target>; 319 | onDrop?: DragEventHandler<Target>; 320 | onDropCapture?: DragEventHandler<Target>; 321 | onMouseDown?: MouseEventHandler<Target>; 322 | onMouseDownCapture?: MouseEventHandler<Target>; 323 | onMouseEnter?: MouseEventHandler<Target>; 324 | onMouseEnterCapture?: MouseEventHandler<Target>; 325 | onMouseLeave?: MouseEventHandler<Target>; 326 | onMouseLeaveCapture?: MouseEventHandler<Target>; 327 | onMouseMove?: MouseEventHandler<Target>; 328 | onMouseMoveCapture?: MouseEventHandler<Target>; 329 | onMouseOut?: MouseEventHandler<Target>; 330 | onMouseOutCapture?: MouseEventHandler<Target>; 331 | onMouseOver?: MouseEventHandler<Target>; 332 | onMouseOverCapture?: MouseEventHandler<Target>; 333 | onMouseUp?: MouseEventHandler<Target>; 334 | onMouseUpCapture?: MouseEventHandler<Target>; 335 | 336 | // Selection Events 337 | onSelect?: GenericEventHandler<Target>; 338 | onSelectCapture?: GenericEventHandler<Target>; 339 | 340 | // Touch Events 341 | onTouchCancel?: TouchEventHandler<Target>; 342 | onTouchCancelCapture?: TouchEventHandler<Target>; 343 | onTouchEnd?: TouchEventHandler<Target>; 344 | onTouchEndCapture?: TouchEventHandler<Target>; 345 | onTouchMove?: TouchEventHandler<Target>; 346 | onTouchMoveCapture?: TouchEventHandler<Target>; 347 | onTouchStart?: TouchEventHandler<Target>; 348 | onTouchStartCapture?: TouchEventHandler<Target>; 349 | 350 | // Pointer Events 351 | onPointerOver?: PointerEventHandler<Target>; 352 | onPointerOverCapture?: PointerEventHandler<Target>; 353 | onPointerEnter?: PointerEventHandler<Target>; 354 | onPointerEnterCapture?: PointerEventHandler<Target>; 355 | onPointerDown?: PointerEventHandler<Target>; 356 | onPointerDownCapture?: PointerEventHandler<Target>; 357 | onPointerMove?: PointerEventHandler<Target>; 358 | onPointerMoveCapture?: PointerEventHandler<Target>; 359 | onPointerUp?: PointerEventHandler<Target>; 360 | onPointerUpCapture?: PointerEventHandler<Target>; 361 | onPointerCancel?: PointerEventHandler<Target>; 362 | onPointerCancelCapture?: PointerEventHandler<Target>; 363 | onPointerOut?: PointerEventHandler<Target>; 364 | onPointerOutCapture?: PointerEventHandler<Target>; 365 | onPointerLeave?: PointerEventHandler<Target>; 366 | onPointerLeaveCapture?: PointerEventHandler<Target>; 367 | onGotPointerCapture?: PointerEventHandler<Target>; 368 | onGotPointerCaptureCapture?: PointerEventHandler<Target>; 369 | onLostPointerCapture?: PointerEventHandler<Target>; 370 | onLostPointerCaptureCapture?: PointerEventHandler<Target>; 371 | 372 | // UI Events 373 | onScroll?: UIEventHandler<Target>; 374 | onScrollCapture?: UIEventHandler<Target>; 375 | 376 | // Wheel Events 377 | onWheel?: WheelEventHandler<Target>; 378 | onWheelCapture?: WheelEventHandler<Target>; 379 | 380 | // Animation Events 381 | onAnimationStart?: AnimationEventHandler<Target>; 382 | onAnimationStartCapture?: AnimationEventHandler<Target>; 383 | onAnimationEnd?: AnimationEventHandler<Target>; 384 | onAnimationEndCapture?: AnimationEventHandler<Target>; 385 | onAnimationIteration?: AnimationEventHandler<Target>; 386 | onAnimationIterationCapture?: AnimationEventHandler<Target>; 387 | 388 | // Transition Events 389 | onTransitionEnd?: TransitionEventHandler<Target>; 390 | onTransitionEndCapture?: TransitionEventHandler<Target>; 391 | }; 392 | 393 | // Note: HTML elements will also need GenericEventAttributes 394 | export declare type HTMLAttrs = { 395 | // Standard HTML Attributes 396 | accept?: string; 397 | acceptCharset?: string; 398 | accessKey?: string; 399 | action?: string; 400 | allowFullScreen?: boolean; 401 | allowTransparency?: boolean; 402 | alt?: string; 403 | as?: string; 404 | async?: boolean; 405 | autocomplete?: string; 406 | autoComplete?: string; 407 | autocorrect?: string; 408 | autoCorrect?: string; 409 | autofocus?: boolean; 410 | autoFocus?: boolean; 411 | autoPlay?: boolean; 412 | capture?: boolean; 413 | cellPadding?: number | string; 414 | cellSpacing?: number | string; 415 | charSet?: string; 416 | challenge?: string; 417 | checked?: boolean; 418 | class?: string; 419 | className?: string; 420 | cols?: number; 421 | colSpan?: number; 422 | content?: string; 423 | contentEditable?: boolean; 424 | contextMenu?: string; 425 | controls?: boolean; 426 | controlsList?: string; 427 | coords?: string; 428 | crossOrigin?: string; 429 | data?: string; 430 | dateTime?: string; 431 | default?: boolean; 432 | defer?: boolean; 433 | dir?: 'auto' | 'rtl' | 'ltr'; 434 | disabled?: boolean; 435 | disableRemotePlayback?: boolean; 436 | download?: unknown; 437 | draggable?: boolean; 438 | encType?: string; 439 | form?: string; 440 | formAction?: string; 441 | formEncType?: string; 442 | formMethod?: string; 443 | formNoValidate?: boolean; 444 | formTarget?: string; 445 | frameBorder?: number | string; 446 | headers?: string; 447 | height?: number | string; 448 | hidden?: boolean; 449 | high?: number; 450 | href?: string; 451 | hrefLang?: string; 452 | for?: string; 453 | htmlFor?: string; 454 | httpEquiv?: string; 455 | icon?: string; 456 | id?: string; 457 | inputMode?: string; 458 | integrity?: string; 459 | is?: string; 460 | keyParams?: string; 461 | keyType?: string; 462 | kind?: string; 463 | label?: string; 464 | lang?: string; 465 | list?: string; 466 | loop?: boolean; 467 | low?: number; 468 | manifest?: string; 469 | marginHeight?: number; 470 | marginWidth?: number; 471 | max?: number | string; 472 | maxLength?: number; 473 | media?: string; 474 | mediaGroup?: string; 475 | method?: string; 476 | min?: number | string; 477 | minLength?: number; 478 | multiple?: boolean; 479 | muted?: boolean; 480 | name?: string; 481 | nonce?: string; 482 | noValidate?: boolean; 483 | open?: boolean; 484 | optimum?: number; 485 | pattern?: string; 486 | placeholder?: string; 487 | playsInline?: boolean; 488 | poster?: string; 489 | preload?: string; 490 | radioGroup?: string; 491 | readOnly?: boolean; 492 | rel?: string; 493 | required?: boolean; 494 | role?: string; 495 | rows?: number; 496 | rowSpan?: number; 497 | sandbox?: string; 498 | scope?: string; 499 | scoped?: boolean; 500 | scrolling?: string; 501 | seamless?: boolean; 502 | selected?: boolean; 503 | shape?: string; 504 | size?: number; 505 | sizes?: string; 506 | slot?: string; 507 | span?: number; 508 | spellcheck?: boolean; 509 | src?: string; 510 | srcset?: string; 511 | srcDoc?: string; 512 | srcLang?: string; 513 | srcSet?: string; 514 | start?: number; 515 | step?: number | string; 516 | style?: string | { [key: string]: string | number }; 517 | summary?: string; 518 | tabIndex?: number; 519 | target?: string; 520 | title?: string; 521 | type?: string; 522 | useMap?: string; 523 | value?: string | string[] | number; 524 | volume?: string | number; 525 | width?: number | string; 526 | wmode?: string; 527 | wrap?: string; 528 | 529 | // RDFa Attributes 530 | about?: string; 531 | datatype?: string; 532 | inlist?: unknown; 533 | prefix?: string; 534 | property?: string; 535 | resource?: string; 536 | typeof?: string; 537 | vocab?: string; 538 | 539 | // Microdata Attributes 540 | itemProp?: string; 541 | itemScope?: boolean; 542 | itemType?: string; 543 | itemID?: string; 544 | itemRef?: string; 545 | }; 546 | 547 | // Note: SVG elements will also need HTMLAttributes and GenericEventAttributes 548 | export declare type SVGAttrs = { 549 | accentHeight?: number | string; 550 | accumulate?: 'none' | 'sum'; 551 | additive?: 'replace' | 'sum'; 552 | alignmentBaseline?: 553 | | 'auto' 554 | | 'baseline' 555 | | 'before-edge' 556 | | 'text-before-edge' 557 | | 'middle' 558 | | 'central' 559 | | 'after-edge' 560 | | 'text-after-edge' 561 | | 'ideographic' 562 | | 'alphabetic' 563 | | 'hanging' 564 | | 'mathematical' 565 | | 'inherit'; 566 | allowReorder?: 'no' | 'yes'; 567 | alphabetic?: number | string; 568 | amplitude?: number | string; 569 | arabicForm?: 'initial' | 'medial' | 'terminal' | 'isolated'; 570 | ascent?: number | string; 571 | attributeName?: string; 572 | attributeType?: string; 573 | autoReverse?: number | string; 574 | azimuth?: number | string; 575 | baseFrequency?: number | string; 576 | baselineShift?: number | string; 577 | baseProfile?: number | string; 578 | bbox?: number | string; 579 | begin?: number | string; 580 | bias?: number | string; 581 | by?: number | string; 582 | calcMode?: number | string; 583 | capHeight?: number | string; 584 | clip?: number | string; 585 | clipPath?: string; 586 | clipPathUnits?: number | string; 587 | clipRule?: number | string; 588 | colorInterpolation?: number | string; 589 | colorInterpolationFilters?: 'auto' | 'sRGB' | 'linearRGB' | 'inherit'; 590 | colorProfile?: number | string; 591 | colorRendering?: number | string; 592 | contentScriptType?: number | string; 593 | contentStyleType?: number | string; 594 | cursor?: number | string; 595 | cx?: number | string; 596 | cy?: number | string; 597 | d?: string; 598 | decelerate?: number | string; 599 | descent?: number | string; 600 | diffuseConstant?: number | string; 601 | direction?: number | string; 602 | display?: number | string; 603 | divisor?: number | string; 604 | dominantBaseline?: number | string; 605 | dur?: number | string; 606 | dx?: number | string; 607 | dy?: number | string; 608 | edgeMode?: number | string; 609 | elevation?: number | string; 610 | enableBackground?: number | string; 611 | end?: number | string; 612 | exponent?: number | string; 613 | externalResourcesRequired?: number | string; 614 | fill?: string; 615 | fillOpacity?: number | string; 616 | fillRule?: 'nonzero' | 'evenodd' | 'inherit'; 617 | filter?: string; 618 | filterRes?: number | string; 619 | filterUnits?: number | string; 620 | floodColor?: number | string; 621 | floodOpacity?: number | string; 622 | focusable?: number | string; 623 | fontFamily?: string; 624 | fontSize?: number | string; 625 | fontSizeAdjust?: number | string; 626 | fontStretch?: number | string; 627 | fontStyle?: number | string; 628 | fontVariant?: number | string; 629 | fontWeight?: number | string; 630 | format?: number | string; 631 | from?: number | string; 632 | fx?: number | string; 633 | fy?: number | string; 634 | g1?: number | string; 635 | g2?: number | string; 636 | glyphName?: number | string; 637 | glyphOrientationHorizontal?: number | string; 638 | glyphOrientationVertical?: number | string; 639 | glyphRef?: number | string; 640 | gradientTransform?: string; 641 | gradientUnits?: string; 642 | hanging?: number | string; 643 | horizAdvX?: number | string; 644 | horizOriginX?: number | string; 645 | ideographic?: number | string; 646 | imageRendering?: number | string; 647 | in2?: number | string; 648 | in?: string; 649 | intercept?: number | string; 650 | k1?: number | string; 651 | k2?: number | string; 652 | k3?: number | string; 653 | k4?: number | string; 654 | k?: number | string; 655 | kernelMatrix?: number | string; 656 | kernelUnitLength?: number | string; 657 | kerning?: number | string; 658 | keyPoints?: number | string; 659 | keySplines?: number | string; 660 | keyTimes?: number | string; 661 | lengthAdjust?: number | string; 662 | letterSpacing?: number | string; 663 | lightingColor?: number | string; 664 | limitingConeAngle?: number | string; 665 | local?: number | string; 666 | markerEnd?: string; 667 | markerHeight?: number | string; 668 | markerMid?: string; 669 | markerStart?: string; 670 | markerUnits?: number | string; 671 | markerWidth?: number | string; 672 | mask?: string; 673 | maskContentUnits?: number | string; 674 | maskUnits?: number | string; 675 | mathematical?: number | string; 676 | mode?: number | string; 677 | numOctaves?: number | string; 678 | offset?: number | string; 679 | opacity?: number | string; 680 | operator?: number | string; 681 | order?: number | string; 682 | orient?: number | string; 683 | orientation?: number | string; 684 | origin?: number | string; 685 | overflow?: number | string; 686 | overlinePosition?: number | string; 687 | overlineThickness?: number | string; 688 | paintOrder?: number | string; 689 | panose1?: number | string; 690 | pathLength?: number | string; 691 | patternContentUnits?: string; 692 | patternTransform?: number | string; 693 | patternUnits?: string; 694 | pointerEvents?: number | string; 695 | points?: string; 696 | pointsAtX?: number | string; 697 | pointsAtY?: number | string; 698 | pointsAtZ?: number | string; 699 | preserveAlpha?: number | string; 700 | preserveAspectRatio?: string; 701 | primitiveUnits?: number | string; 702 | r?: number | string; 703 | radius?: number | string; 704 | refX?: number | string; 705 | refY?: number | string; 706 | renderingIntent?: number | string; 707 | repeatCount?: number | string; 708 | repeatDur?: number | string; 709 | requiredExtensions?: number | string; 710 | requiredFeatures?: number | string; 711 | restart?: number | string; 712 | result?: string; 713 | rotate?: number | string; 714 | rx?: number | string; 715 | ry?: number | string; 716 | scale?: number | string; 717 | seed?: number | string; 718 | shapeRendering?: number | string; 719 | slope?: number | string; 720 | spacing?: number | string; 721 | specularConstant?: number | string; 722 | specularExponent?: number | string; 723 | speed?: number | string; 724 | spreadMethod?: string; 725 | startOffset?: number | string; 726 | stdDeviation?: number | string; 727 | stemh?: number | string; 728 | stemv?: number | string; 729 | stitchTiles?: number | string; 730 | stopColor?: string; 731 | stopOpacity?: number | string; 732 | strikethroughPosition?: number | string; 733 | strikethroughThickness?: number | string; 734 | string?: number | string; 735 | stroke?: string; 736 | strokeDasharray?: string | number; 737 | strokeDashoffset?: string | number; 738 | strokeLinecap?: 'butt' | 'round' | 'square' | 'inherit'; 739 | strokeLinejoin?: 'miter' | 'round' | 'bevel' | 'inherit'; 740 | strokeMiterlimit?: string; 741 | strokeOpacity?: number | string; 742 | strokeWidth?: number | string; 743 | surfaceScale?: number | string; 744 | systemLanguage?: number | string; 745 | tableValues?: number | string; 746 | targetX?: number | string; 747 | targetY?: number | string; 748 | textAnchor?: string; 749 | textDecoration?: number | string; 750 | textLength?: number | string; 751 | textRendering?: number | string; 752 | to?: number | string; 753 | transform?: string; 754 | u1?: number | string; 755 | u2?: number | string; 756 | underlinePosition?: number | string; 757 | underlineThickness?: number | string; 758 | unicode?: number | string; 759 | unicodeBidi?: number | string; 760 | unicodeRange?: number | string; 761 | unitsPerEm?: number | string; 762 | vAlphabetic?: number | string; 763 | values?: string; 764 | vectorEffect?: number | string; 765 | version?: string; 766 | vertAdvY?: number | string; 767 | vertOriginX?: number | string; 768 | vertOriginY?: number | string; 769 | vHanging?: number | string; 770 | vIdeographic?: number | string; 771 | viewBox?: string; 772 | viewTarget?: number | string; 773 | visibility?: number | string; 774 | vMathematical?: number | string; 775 | widths?: number | string; 776 | wordSpacing?: number | string; 777 | writingMode?: number | string; 778 | x1?: number | string; 779 | x2?: number | string; 780 | x?: number | string; 781 | xChannelSelector?: string; 782 | xHeight?: number | string; 783 | xlinkActuate?: string; 784 | xlinkArcrole?: string; 785 | xlinkHref?: string; 786 | xlinkRole?: string; 787 | xlinkShow?: string; 788 | xlinkTitle?: string; 789 | xlinkType?: string; 790 | xmlBase?: string; 791 | xmlLang?: string; 792 | xmlns?: string; 793 | xmlnsXlink?: string; 794 | xmlSpace?: string; 795 | y1?: number | string; 796 | y2?: number | string; 797 | y?: number | string; 798 | yChannelSelector?: string; 799 | z?: number | string; 800 | zoomAndPan?: string; 801 | }; 802 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | // Haptic's reactive state engine 2 | 3 | type Signal<T = unknown> = { 4 | /** Read value */ 5 | (): T; 6 | /** Write value; notifying wires */ // Ordered before ($):T for TS to work 7 | (value: T): void; 8 | /** Read value & subscribe */ 9 | ($: SubToken): T; 10 | /** Wires subscribed to this signal */ 11 | wires: Set<Wire<X>>; 12 | /** Transaction value; set and deleted on commit */ 13 | next?: T; 14 | /** If this is a computed-signal, this is its wire */ 15 | cw?: Wire<T>; 16 | /** To check "if x is a signal" */ 17 | $signal: 1; 18 | }; 19 | 20 | type Wire<T = unknown> = { 21 | /** Run the wire */ 22 | (): T; 23 | /** Signals read-subscribed last run */ 24 | sigRS: Set<Signal<X>>; 25 | /** Signals read-passed last run */ 26 | sigRP: Set<Signal<X>>; 27 | /** Signals inherited from computed-signals, for consistent two-way linking */ 28 | sigIC: Set<Signal<X>>; 29 | /** Post-run tasks */ 30 | tasks: Set<(nextValue: T) => void>; 31 | /** Wire that created this wire (parent of this child) */ 32 | upper: Wire<X> | undefined; 33 | /** Wires created during this run (children of this parent) */ 34 | lower: Set<Wire<X>>; 35 | /** FSM state 3-bit bitmask: [RUNNING][SKIP_RUN_QUEUE][NEEDS_RUN] */ 36 | state: WireState; 37 | /** Run count */ 38 | run: number; 39 | /** If part of a computed signal, this is its signal */ 40 | cs?: Signal<T>; 41 | /** To check "if x is a wire" */ 42 | $wire: 1; 43 | }; 44 | 45 | type SubToken = { 46 | /** Allow $(...signals) to return an array of read values */ 47 | <U extends Array<() => unknown>>(...args: U): { 48 | [P in keyof U]: U[P] extends Signal<infer R> ? R : never 49 | }; 50 | /** Wire to subscribe to */ 51 | wire: Wire<X>; 52 | /** To check "if x is a subscription token" */ 53 | $$: 1; 54 | }; 55 | 56 | /** 3 bits: [RUNNING][SKIP_RUN_QUEUE][NEEDS_RUN] */ 57 | type WireState = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; 58 | 59 | /* eslint-disable no-multi-spaces */ 60 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 61 | type X = any; 62 | type P<T> = Partial<T>; 63 | 64 | let wireId = 0; 65 | let signalId = 0; 66 | 67 | // Currently running wire 68 | let activeWire: Wire<X> | undefined; 69 | // Signals written to during a transaction 70 | let transactionSignals: Set<Signal<X>> | undefined; 71 | let transactionCommit = false; 72 | 73 | // Symbol() doesn't gzip well. `[] as const` gzips best but isn't debuggable 74 | // without a lookup Map<> and other hacks. 75 | declare const S_RUNNING = 0b100; 76 | declare const S_SKIP_RUN_QUEUE = 0b010; 77 | declare const S_NEEDS_RUN = 0b001; 78 | 79 | /** 80 | * Void subcription token. Used when a function demands a token but you don't 81 | * want to consent to any signal subscriptions. */ 82 | // @ts-ignore 83 | const v$: SubToken = ((...signals) => signals.map((sig) => sig(v$))); 84 | 85 | // In signalBase() and createWire() `{ [id]() {} }[id]` preserves the function 86 | // name which is useful for debugging 87 | 88 | /** 89 | * Create a wire. Activate the wire by running it (function call). Any signals 90 | * that read-subscribed during the run will re-run the wire later when written 91 | * to. Wires can be run anytime manually. They're pausable and are resumed when 92 | * called; resuming will avoid a wire run if the wire is not stale. Wires are 93 | * named by their function's name and a counter. */ 94 | const createWire = <T>(fn: ($: SubToken) => T): Wire<T> => { 95 | const id = `wire|${wireId++}{${fn.name}}`; 96 | let saved: T; 97 | // @ts-ignore Missing properties right now but they're set in _initWire() 98 | const wire: Wire<T> = { [id]() { 99 | if (wire.state & S_RUNNING) { 100 | throw new Error(`Loop ${wire.name}`); 101 | } 102 | // Symmetrically remove all connections between signals and wires. This is 103 | // called "automatic memory management" in Sinuous/S.js 104 | wireReset(wire); 105 | wire.state |= S_RUNNING; 106 | wireAdopt(wire, () => saved = fn($)); 107 | wire.tasks.forEach((task) => task(saved)); 108 | wire.run++; 109 | wire.state &= ~(S_RUNNING | S_NEEDS_RUN); 110 | return saved; 111 | } }[id]; 112 | // @ts-ignore 113 | const $: SubToken = ((...sig) => sig.map((_sig) => _sig($))); 114 | $.$$ = 1; 115 | $.wire = wire; 116 | if (activeWire) activeWire.lower.add(wire); 117 | wire.upper = activeWire; 118 | wire.tasks = new Set(); 119 | wire.$wire = 1; 120 | wire.run = 0; 121 | // Outside of _initWire because this persists across wire resets 122 | _initWire(wire); 123 | return wire; 124 | }; 125 | 126 | const _initWire = (wire: Wire<X>): void => { 127 | wire.state = S_NEEDS_RUN; 128 | wire.lower = new Set(); 129 | // Drop all signals now that they have been unlinked 130 | wire.sigRS = new Set(); 131 | wire.sigRP = new Set(); 132 | wire.sigIC = new Set(); 133 | }; 134 | 135 | const _runWires = (wires: Set<Wire<X>>): void => { 136 | // Use a new Set() to avoid infinite loops caused by wires writing to signals 137 | // during their run. 138 | const toRun = new Set(wires); 139 | let curr: Wire<X> | undefined; 140 | // Mark upstream computeds as stale. Must be in an isolated for-loop 141 | toRun.forEach((wire) => { 142 | if (wire.cs || wire.state & S_SKIP_RUN_QUEUE) { 143 | toRun.delete(wire); 144 | wire.state |= S_NEEDS_RUN; 145 | } 146 | // TODO: Test (#3) + Benchmark with main branch 147 | // If a wire's ancestor will run it'll destroy its lower wires. It's more 148 | // efficient to not call them at all by deleting from the run list: 149 | curr = wire; 150 | while ((curr = curr.upper)) 151 | if (toRun.has(curr)) return toRun.delete(wire); 152 | }); 153 | toRun.forEach((wire) => wire() as void); 154 | }; 155 | 156 | /** 157 | * Removes two-way subscriptions between its signals and itself. This also turns 158 | * off the wire until it is manually re-run. */ 159 | const wireReset = (wire: Wire<X>): void => { 160 | wire.lower.forEach(wireReset); 161 | wire.sigRS.forEach((signal) => signal.wires.delete(wire)); 162 | wire.sigIC.forEach((signal) => signal.wires.delete(wire)); 163 | _initWire(wire); 164 | }; 165 | 166 | /** 167 | * Pauses a wire so signal writes won't cause runs. Affects nested wires */ 168 | const wirePause = (wire: Wire<X>): void => { 169 | wire.lower.forEach(wirePause); 170 | wire.state |= S_SKIP_RUN_QUEUE; 171 | }; 172 | 173 | /** 174 | * Resumes a paused wire. Affects nested wires but skips wires belonging to 175 | * computed-signals. Returns true if any runs were missed during the pause */ 176 | const wireResume = (wire: Wire<X>): boolean => { 177 | wire.lower.forEach(wireResume); 178 | // Clears SKIP_RUN_QUEUE only if it's NOT a computed-signal 179 | if (!wire.cs) wire.state &= ~S_SKIP_RUN_QUEUE; 180 | // eslint-disable-next-line no-implicit-coercion 181 | return !!(wire.state & S_NEEDS_RUN); 182 | }; 183 | 184 | const signalBase = <T>(value: T, id = ''): Signal<T> => { 185 | type W = Wire<X>; 186 | let saved: unknown; 187 | let cwTask: ((value: unknown) => void) | undefined; 188 | // Multi-use temp variable 189 | let read: unknown = `signal|${signalId++}{${id}}`; 190 | const signal = { [read as string](...args: [$?: SubToken, ..._: unknown[]]) { 191 | // Case: Read-Pass. Marks the active running wire as a reader 192 | if ((read = !args.length)) { 193 | if (activeWire) { 194 | if (activeWire.sigRS.has(signal)) { 195 | throw new Error(`${activeWire.name} mixes sig($) & sig()`); 196 | } 197 | activeWire.sigRP.add(signal); 198 | } 199 | } 200 | // Case: Void token 201 | else if ((read = args[0] === v$)) {} 202 | // Case: Read-Subscribe. Marks the wire registered in `$` as a reader 203 | // This could be different than the actively running wire, but shouldn't be 204 | else if ((read = args[0] && (args[0] as P<SubToken>).$$ && args[0].wire)) { 205 | if ((read as W).sigRP.has(signal)) { 206 | throw new Error(`${(read as W).name} mixes sig($) & sig()`); 207 | } 208 | // Two-way link. Signal writes will now call/update wire W 209 | (read as W).sigRS.add(signal); 210 | signal.wires.add((read as W)); 211 | 212 | // Computed-signals can't only run W when written to, they also need to 213 | // run W when signal.cw is marked stale. How do we know when that happens? 214 | // It's when a cw.sigRS signal tries to call signal.cw. So adding `read` 215 | // to each signal in cw.sigRS will call W as collateral. 216 | if (signal.cw) { 217 | // Run early if sigRS isn't ready (see "Update if needed" line below) 218 | if (signal.cw.state & S_NEEDS_RUN) signal.cw(); 219 | signal.cw.sigRS.forEach((_signal) => { 220 | // Linking _must_ be two-way. From signal.wires to wire.sigXYZ. Until 221 | // now it's always either sigRS or sigRP, but if we use those we'll 222 | // break the mix error checking (above). So use a new list, sigIC. 223 | (read as W).sigIC.add(_signal); 224 | _signal.wires.add((read as W)); 225 | }); 226 | } 227 | } 228 | // Case: Write 229 | else { 230 | // If in a transaction; defer saving the value 231 | if (transactionSignals) { 232 | transactionSignals.add(signal); 233 | signal.next = args[0] as unknown as T; 234 | return; 235 | } 236 | // If overwriting a computed-signal wire, unsubscribe the wire 237 | if (signal.cw) { 238 | signal.cw.tasks.delete(cwTask as () => void); 239 | wireReset(signal.cw); 240 | delete signal.cw.cs; 241 | delete signal.cw; 242 | // cwTask = undefined; 243 | } 244 | saved = args[0] as unknown as T; 245 | // If writing a wire, this signal becomes as a computed-signal 246 | if (saved && (saved as P<Wire>).$wire) { 247 | (saved as W).cs = signal; 248 | (saved as W).state |= S_SKIP_RUN_QUEUE; 249 | (saved as W).tasks.add(cwTask = (value) => saved = value); 250 | signal.cw = saved as W; 251 | // saved = undefined; 252 | } 253 | // Notify every write _unless_ this is a post-transaction commit 254 | if (!transactionCommit) _runWires(signal.wires); 255 | } 256 | if (read) { 257 | // Update if needed 258 | if (signal.cw && signal.cw.state & S_NEEDS_RUN) signal.cw(); 259 | return saved; 260 | } 261 | } }[read as string] as Signal<T>; 262 | signal.$signal = 1; 263 | signal.wires = new Set<Wire<X>>(); 264 | // Call it to run the "Case: Write" and de|initialize computed-signals 265 | signal(value); 266 | return signal; 267 | }; 268 | 269 | /** 270 | * Creates signals for each object entry. Signals are read/write variables which 271 | * hold a list of subscribed wires. When a value is written those wires are 272 | * re-run. Writing a wire into a signal creates a lazy computed-signal. Signals 273 | * are named by the key of the object entry and a global counter. */ 274 | const createSignal = <T>(obj: T): { 275 | [K in keyof T]: Signal<T[K] extends Wire<infer R> ? R : T[K]>; 276 | } => { 277 | Object.keys(obj).forEach((k) => { 278 | // @ts-ignore Mutation of T 279 | obj[k] = signalBase(obj[k as keyof T], k); 280 | signalId--; 281 | }); 282 | signalId++; 283 | // @ts-ignore Mutation of T 284 | return obj; 285 | }; 286 | createSignal.anon = signalBase; 287 | 288 | /** 289 | * Batch signal writes so only the last write per signal is applied. Values are 290 | * committed at the end of the function call. */ 291 | const transaction = <T>(fn: () => T): T => { 292 | const prev = transactionSignals; 293 | transactionSignals = new Set(); 294 | let error: unknown; 295 | let ret: unknown; 296 | try { 297 | ret = fn(); 298 | const signals = transactionSignals; 299 | transactionSignals = prev; 300 | const transactionWires = new Set<Wire<X>>(); 301 | transactionCommit = true; 302 | signals.forEach((signal) => { 303 | // Doesn't run any subscribed wires since `transactionCommit` is set 304 | signal(signal.next); 305 | delete signal.next; 306 | signal.wires.forEach((wire) => transactionWires.add(wire)); 307 | }); 308 | transactionCommit = false; 309 | _runWires(transactionWires); 310 | } catch (err) { 311 | error = err; 312 | } 313 | // Yes this happens a few lines up; do it again in case the `try` throws 314 | transactionSignals = prev; 315 | if (error) throw error; 316 | return ret as T; 317 | }; 318 | 319 | /** 320 | * Run a function within the context of a wire. Wires created in this context 321 | * are adopted (see wire.lower). This affects signal read consistency checks for 322 | * read-pass (signal.sigRP) and read-subscribe (signal.sigRS). */ 323 | const wireAdopt = <T>(wire: Wire<X> | undefined, fn: () => T): void => { 324 | const prev = activeWire; 325 | activeWire = wire; 326 | let error: unknown; 327 | // Note: Can't use try+finally it swallows the error instead of throwing 328 | try { 329 | fn(); 330 | } catch (err) { 331 | error = err; 332 | } 333 | activeWire = prev; 334 | if (error) throw error; 335 | }; 336 | 337 | export { 338 | // Using createX avoids variable shadowing, rename here 339 | createSignal as signal, 340 | createWire as wire, 341 | wireReset, 342 | wirePause, 343 | wireResume, 344 | wireAdopt, 345 | transaction, 346 | v$ // Actual subtokens are only ever provided by a wire 347 | }; 348 | 349 | export type { Signal, Wire, WireState, SubToken }; 350 | -------------------------------------------------------------------------------- /src/state/readme.md: -------------------------------------------------------------------------------- 1 | # Reactive state 2 | 3 | Defines __signals__ as read/write reactive variables, and __wires__ as reactive 4 | functions. These are linked together into "subscriptions" to enable reactivity. 5 | 6 | Subscriptions are a two-way linking between wires and signals, and are setup 7 | when a wire runs its function. After, when a signal is written to, it runs all 8 | of its subscribed wires. Subscribing is an explicit opt-in process, explained 9 | below; there's no need for a `sample()` function found in other libraries. 10 | 11 | The reactivity engine is resilient to errors. Individual signals and wires can 12 | throw without disrupting the system. To help debugging, meaningful function 13 | names are generated for both signals and wires and these show up naturally in 14 | developer tools, console.log, and error stacktraces. 15 | 16 | It's 908 bytes min+gzip on its own. 17 | 18 | It's normal ESM and can be used by itself in any JS environment, no DOM needed. 19 | 20 | ## Signals 21 | 22 | These are reactive read/write variables who notify subscribers when they've been 23 | written to. They are the only dispatchers in the reactive system. 24 | 25 | ```ts 26 | const state = signal({ 27 | name: 'Deciduous Willow', 28 | age: 85, 29 | }); 30 | 31 | state.name; // [Function: signal|0{name}] 32 | state.name(); // 'Deciduous Willow' 33 | state.name('Willow'); 34 | state.name(); // 'Willow' 35 | ``` 36 | 37 | The subscribers to signals are wires, which will be introduced next. They 38 | subscribe by read-subscribing the signal. This is an important distinction - 39 | signals have two types of reads! 40 | 41 | ```ts 42 | state.name(); // Passive read (read-pass) 43 | state.name($); // Subscribed read (read-subscribe) 44 | ``` 45 | 46 | This is unlike other reactive libraries, but it'll save us a lot of debugging. 47 | Separating the reads it makes subscribed reads an explicit and visually distinct 48 | action from passive reads. This makes Haptic an opt-in design, and it doesn't 49 | need the `sample()` function seen in other libraries. This is explained later 50 | when introducing wires, which is also where the `$` value comes from. 51 | 52 | Both signals and wires are tagged with a name and identifier to help debugging. 53 | In the above example it was `signal|0{name}`. This is the actual JS function 54 | name, so developer tools will show it by default when inspecting values, logging 55 | to the console, and in error stacktraces. It helps greatly with visualizing the 56 | subscriptions between signals and wires. 57 | 58 | Sometimes defining an entire state object can be too much overhead. To skip 59 | naming, there's a `signal.anon()` function to directly return an unnamed signal 60 | that's not packed into an object. 61 | 62 | ```ts 63 | // Named as [Function: signal|1{ans}] 64 | const { ans } = signal({ ans: 100 }); 65 | 66 | // Anonymous as [Function: signal|1{}] 67 | const ans = signal.anon(100); 68 | ``` 69 | 70 | Any value can be written and stored in a signal, but if a wire is written, the 71 | signal becomes a __lazy computed-signal__ that returns the result of the wire's 72 | function. It's like using a formula in a spreadsheet. These are really efficient 73 | and only rerun the wire when dependencies mark the result as stale. These are 74 | introduced later on. 75 | 76 | ## Wires 77 | 78 | These are task runners who subscribe to signals and react to signal writes. They 79 | hold a function (the task) and manage its subscriptions, nested wires, run 80 | count, and other metadata. The wire provides a "\$" token to the function call 81 | that, at your discretion as the developer, can use to read-subscribe to signals. 82 | 83 | ```ts 84 | wire($ => { 85 | // Explicitly subscribe to state.name using the subtoken "$" 86 | console.log("Update to state.name:", state.name($)); 87 | }) 88 | ``` 89 | 90 | Earlier, when introducing signals, I mentioned a `sample()` method isn't needed. 91 | Let's dive into that. Consider this code: 92 | 93 | ```ts 94 | wire($ => { 95 | const name = state.name($); 96 | console.log("Update to state.name:", name); 97 | // Calling a function... 98 | if (name.length > 10) pushToNameQueue(name); 99 | }) 100 | ``` 101 | 102 | **_Is this safe?_** i.e can we predict the subscriptions for this system? In 103 | many reactive libraries the answer is no... We don't know what's happening in 104 | that function call, so it could make any number of subscriptions by reading 105 | other signals. These accidental subscriptions would cause unexpected runs that 106 | can be hard to debug. **In Haptic, it's safe**. The `$` token wasn't passed to 107 | the function call, so we can guarantee our wire only subscribes to `state.name`. 108 | 109 | In other libraries you need to remember to wrap all function calls in `sample()` 110 | to opt-out of subscriptions. In Haptic, you pass around "$". 111 | 112 | Here's some other features about wires: 113 | 114 | - They track which signals are read-passed and which are read-subscribed to 115 | maintain read consistency; if the same signal does `sig($)` and `sig()` the 116 | wire will throw. 117 | 118 | - They're finite-state-machines that can be reset, running, idle, paused, and 119 | stale. They use the FSM state to stop infinite loops, skip being run when 120 | paused or when part of a computed-signal, and knowing if they need to run 121 | once they're resumed. 122 | 123 | - They keep track of how many times they've run. This is useful for profiling 124 | and debugging. 125 | 126 | - The wire function has post-run tasks which are used to piggyback on a wire 127 | run in a non-destructive way. It may not seem immediately useful, but this 128 | is how `api.patch` wires reactivity into the DOM and is also why a single 129 | wire to be patched into multiple places in the DOM. Computed-signals update 130 | their stored values this way too. 131 | 132 | - In the rare case that a function (maybe third party) requires a "\$" token 133 | as a parameter but you don't want to consent to unknown subscriptions in 134 | your wire, you can import and pass the void-token "\$v" instead. 135 | 136 | Lastly, this is a more complex example using wires and signals to build a small 137 | application: 138 | 139 | ```tsx 140 | const data = signal({ 141 | text: '', 142 | count: 0, 143 | }); 144 | 145 | // Wiring in the DOM 146 | document.body.appendChild( 147 | <div> 148 | <h1>"{wire(data.text)}"</h1> 149 | <p>Uses {wire($ => data.text($).length)} characters of text</p> 150 | <input 151 | value={wire(data.text)} 152 | onInput={(ev) => data.text(ev.currentTarget.value)} 153 | /> 154 | </div> 155 | ); 156 | 157 | // Wiring a general subscription, as an effect 158 | wire($ => { 159 | console.log('Text was updated to', data.text($)); // Read-Subscribe 160 | console.log('The count also happens to be', data.count()); // Read-Pass 161 | })(); 162 | ``` 163 | 164 | In the above example, typing in the input box updates the text signal and causes 165 | updates to the DOM and logs to the console. However, updates to the count signal 166 | don't update anything; no one is subscribed. 167 | 168 | ## Computed-Signals 169 | 170 | This is Haptic's version of a `computed()` seen in other reactive libraries. 171 | It's like writing a formula in a spreadsheet cell rather than a static value. 172 | 173 | ```ts 174 | const state = signal({ 175 | name: 'Deciduous Willow', 176 | age: 85, 177 | // This defines a lazy computed-signal 178 | nameReversed: wire(($): string => 179 | state.name($).split('').reverse().join()), 180 | }); 181 | ``` 182 | 183 | The wire for `nameReversed` has never run at this point. It will only run when 184 | the signal is read, such as `state.nameReversed()`. It'll cache this value, so 185 | subsequent reads are cheap and avoid the computation. When `name` is changed, it 186 | marks `nameReversed` as stale, so the next read will require the wire run. 187 | 188 | Here's another example that reads/runs computed-signals "backwards" while still 189 | behaving as expected: 190 | 191 | ```ts 192 | const state = signal({ 193 | count: 45, 194 | countSquared(wire($ => state.count($) ** 2)), 195 | countSquaredPlusFive(wire($ => state.countSquared($) + 5)), 196 | }); 197 | // Note that the computation has never run up to now. They're _lazy_. 198 | 199 | // Calling countSquaredPlusFive will run countSquared, since it's a dependency. 200 | state.countSquaredPlusFive(); // 2030 201 | 202 | // Calling countSquared does _no work_. It's not stale. The value is cached. 203 | state.countSquared(); // 2025 204 | ``` 205 | 206 | --- 207 | 208 | ## Nice principals about state 209 | 210 | - Reading a signal (pass-read) is always safe. There should be no reason to wish 211 | you could snake around the function call by reading the stored value directly. 212 | This is because Haptic is explicit and there's no accidental subscriptions. 213 | 214 | - Wires can always be manually run by calling them and this won't cause other 215 | side-effects within the engine or trigger other chain reactions. It's safe to 216 | debug by calling. 217 | 218 | - Similarly, its expected that people will try interacting with wires and 219 | signals in the console. I try to make that debugging experience nice. 220 | 221 | - There's readable and consistent naming; no shorthand notations in code and 222 | function properties. They also all have nice JSDoc comments for your editor. 223 | 224 | - Computed-signals are lazy and will do their best to avoid redoing work. They 225 | don't even run when initialized. 226 | 227 | - Creating a computed-signal by writing an active/used wire into a signal 228 | provides a _reasonable_ experience but I don't recommend it. The wire will 229 | work as expected **until it is reset/unsubscribed by a subsequent write** to 230 | which replaces the wire. I've prioritized having consistent signal behaviour 231 | so writes _always_ write. I stop the wire so it doesn't keep running in the 232 | void and never get garbage collected. I don't want to throw or complain that 233 | the wire needs to be dealt with, so I default to resetting it. I understand 234 | this doesn't make everyone happy. If you plan to ever convert a 235 | computed-signal to a normal signal take care to re-run the wire if needed. 236 | 237 | ## Concerns 238 | 239 | - Converting a computed-signal to a signal via write isn't very explicit. It 240 | could be an accident. Is that OK? Depends what camp you're in. The "save the 241 | wires" camp wants writes to not disturb the wire. The "signals are signals" 242 | camp wants to maintain consistent write behaviour. Haptic does the latter. 243 | -------------------------------------------------------------------------------- /src/stdlib/index.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'haptic/dom'; 2 | import { wire, wireAdopt, wirePause, wireResume } from 'haptic/state'; 3 | 4 | import type { Wire, SubToken } from 'haptic/state'; 5 | import type { El } from 'haptic/dom'; 6 | 7 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 8 | 9 | /** Switches DOM content by creating a wire responds to a changing condition. */ 10 | const when = <T extends string>( 11 | condition: ($: SubToken) => T, 12 | views: { [k in T]?: () => El } 13 | ): Wire<El | undefined> => { 14 | type ActiveMeta = { elementRoot: El | undefined, wireRoot: Wire<void> }; 15 | const active = {} as { [k in T]?: ActiveMeta }; 16 | let condRendered: T; 17 | return wire(($) => { 18 | // Creates subscriptions to signals (hopefully) 19 | const cond = condition($); 20 | if (cond === condRendered) { 21 | return active[condRendered]!.elementRoot; 22 | } 23 | condRendered = cond; 24 | let a: ActiveMeta | undefined; 25 | // Else, content is changing. Pause wires for the current element 26 | if ((a = active[condRendered])) { 27 | wirePause(a.wireRoot); 28 | } 29 | // Have we rendered this condition before? 30 | if ((a = active[cond])) { 31 | // Then unpause its wires and return its pre-rendered element 32 | const stale = wireResume(a.wireRoot); 33 | if (stale) a.wireRoot(); 34 | return a.elementRoot; 35 | } 36 | // Else, we need to render from scratch 37 | wireAdopt(undefined, () => { 38 | // The above line avoids the upper/lower relationship that's normally made 39 | // when creating a wire in a wire. This root is isolated. 40 | const wireRoot = wire(() => {}); 41 | wireAdopt(wireRoot, () => { 42 | // All wires within views[cond] are adopted to wireRoot for pause/resume 43 | a = active[cond] = { elementRoot: h(views[cond]!), wireRoot }; 44 | }); 45 | }); 46 | return a!.elementRoot; 47 | }); 48 | }; 49 | 50 | export { when }; 51 | -------------------------------------------------------------------------------- /src/stdlib/readme.md: -------------------------------------------------------------------------------- 1 | # Standard library 2 | 3 | These are constructs for control-flow, lifecycles, context, error boundaries, 4 | and other higher-level ideas beyond basic JSX and reactive state. 5 | 6 | Many of these are unimplemented, but are foreseen because they're popular in 7 | other libraries; often popularized by React. **I won't implement unnecessary 8 | constructs, personally.** If I don't use it you won't find it here. I'm also 9 | more aligned to use JS constructs rather than JSX DSL like Solid/React do, hence 10 | having `when()` as a function. 11 | 12 | Implemented, though not necessarily right here: 13 | 14 | - Switch DOM content efficiently via `when()`. It does caching of DOM nodes 15 | and pauses any wires that go "off-screen". 16 | 17 | - Lifecycle hooks for `onAttach` and `onDetach` component mounting: 18 | https://github.com/heyheyhello/sinuous-packages/tree/work/sinuous-lifecycle 19 | 20 | - List reconciliation, a necessary evil at times: 21 | https://github.com/luwes/sinuous/tree/master/packages/sinuous/map 22 | 23 | 24 | Again, these will be implemented as necessary... 25 | 26 | ## `when<T>(condition: ($: SubToken) => T, views: { [key: T]: () => El }): Wire<El | undefined>` 27 | 28 | Uses `condition` function to build and return a wire. This wire matches the 29 | output of the condition to a key in `views` to return DOM content. Try returning 30 | readable values such as "T"/"F" as shown below. When a view is unrendered, all 31 | its nested wires are paused so the view doesn't keep updating in the background. 32 | 33 | Usage: 34 | 35 | ```tsx 36 | import { h } from 'haptic'; 37 | import { signal, wire } from 'haptic/state'; 38 | import { when } from 'haptic/stdlib'; 39 | 40 | const data = signal({ 41 | count: 0, 42 | countNext: wire($ => data.count($) + 1), 43 | }); 44 | 45 | const Page = () => 46 | <div> 47 | <p>Content below changes when <code>data.count > 5</code></p> 48 | <button onClick={() => data.count(data.count() + 1)}> 49 | Increment to {wire(data.countNext)} 50 | </button> 51 | {when($ => data.count($) > 5 ? "T" : "F", { 52 | T: () => <p>There have been more than 5 clicks</p>, 53 | F: () => <p>Current click count is {wire(data.count)}</p>, 54 | })} 55 | </div>; 56 | 57 | document.body.appendChild(<Page/>); 58 | ``` 59 | 60 | When it says "There have been more than 5 clicks" the `wire(data.count)` wire 61 | won't run anymore even when `data.count` is being written to. 62 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | import { Worker, isMainThread, workerData } from 'worker_threads'; 2 | import { watch } from 'fs'; 3 | 4 | // Usage: `node test ./test/**` which expands to full relative paths via shell 5 | 6 | // Previous test runner ideas: 7 | // - Using `fdir` and `picomatch` to find **/*.test.ts. Over-engineered. 8 | // - Using `fs.readdir(TEST_DIR)` paired with CLI substring filters. Ditto. 9 | // - Using `vm.createScript` and `vm.runInNewContext` since ESM doesn't have a 10 | // replacement for `delete require.cache[name]` and reload a module. Insanely 11 | // complicated and experimental for ESM. No bueno. 12 | // - Spawn a Node process and use AbortController: #95f9b198ae19 13 | 14 | if (isMainThread) { 15 | const args = process.argv.slice(2); 16 | const options: string[] = []; 17 | const files: string[] = []; 18 | 19 | for (const arg of args) { 20 | if (arg.startsWith('--')) options.push(arg); 21 | else files.push(arg); 22 | } 23 | 24 | if (files.length === 0) { 25 | console.log('No test files specified'); 26 | process.exit(1); 27 | } 28 | 29 | const watchMode = options.includes('--watch'); 30 | // Automatic transform handled by esbuild-node-loader ✨ 31 | const workerUrl = new URL(`data:text/javascript,import('${import.meta.url}');`); 32 | let worker: Worker | undefined; 33 | 34 | const reload = () => { 35 | if (worker) { 36 | void worker.terminate(); 37 | } 38 | worker = new Worker(workerUrl, { workerData: files }); 39 | worker.on('online', () => { 40 | console.log('Worker started'); 41 | }); 42 | worker.on('error', (err) => { 43 | console.log(`Worker error ❌ ${err}`); 44 | }); 45 | worker.on('exit', (exitCode) => { 46 | if (exitCode === 0) console.log('Worker exit OK'); 47 | if (watchMode) console.log('Waiting for reload...'); 48 | }); 49 | }; 50 | 51 | let debounceTimer: NodeJS.Timeout | undefined; 52 | if (watchMode) { 53 | console.log('Watching for changes'); 54 | for (const file of files) { 55 | watch(file, (eventName) => { 56 | console.log(`Watch ${file} event ${eventName}`); 57 | if (debounceTimer) clearTimeout(debounceTimer); 58 | debounceTimer = setTimeout(() => { 59 | reload(); 60 | }, 500); 61 | }); 62 | } 63 | process.stdin.on('data', (chunk) => { 64 | const text = chunk.toString(); 65 | if (text === 're\n' || text === 'reload\n') { 66 | console.log('Manual reload'); 67 | reload(); 68 | } 69 | }); 70 | } 71 | reload(); 72 | } else { 73 | // Colours in TTY 74 | process.stdout.isTTY = true; 75 | process.stderr.isTTY = true; 76 | 77 | const files = workerData as string[]; 78 | console.log(`Testing ${files.join(', ')}`); 79 | const { hold, report } = await import('zora'); 80 | hold(); 81 | const { createDiffReporter } = await import('zora-reporters'); 82 | // Automatic transform handled by esbuild-node-loader ✨ 83 | await Promise.all(files.map((file) => import(file))); 84 | await report({ reporter: createDiffReporter() }); 85 | } 86 | -------------------------------------------------------------------------------- /test/haptic-dom.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | 3 | void test('hello from zora', ({ ok }) => { 4 | ok(true, 'it worked'); 5 | }); 6 | -------------------------------------------------------------------------------- /test/haptic-state.test.ts: -------------------------------------------------------------------------------- 1 | // These can't be *.js with the Node loader. Use package names once published 2 | import { signal, wire } from '../src/state/index'; 3 | import type { Signal, Wire } from '../src/state/index'; 4 | 5 | import {} from 'zora'; 6 | 7 | // Because these only exist in TypeScript `declare` and esbuild `--define` 8 | Object.assign(globalThis, { 9 | S_RUNNING: 4, 10 | S_SKIP_RUN_QUEUE: 2, 11 | S_NEEDS_RUN: 1, 12 | }); 13 | 14 | // ❯ npm run build 15 | // ❯ cat test/haptic.test.ts | sed 's|../src/state/index.js|haptic/state|g' | npx esbuild --loader=ts | node --input-type=module 16 | 17 | const state = signal({ 18 | count: 0, 19 | countPlusOne: wire(function cPO($): number { 20 | console.log('Computing countPlusOne'); 21 | return state.count($) + 1; 22 | }), 23 | countPlusTwo: wire(function cPT($): number { 24 | console.log('Computing countPlusTwo'); 25 | return state.countPlusOne($) + 1; 26 | }), 27 | }); 28 | 29 | const handlerFor = <T>(name: keyof typeof state): ProxyHandler<Signal<T>> => ({ 30 | apply: function(sig, thisArg, arg) { 31 | if (arg.length === 0) { 32 | console.group(`R: state.${name}()`); 33 | const v = sig(); 34 | console.log(`-> ${v}`); 35 | console.groupEnd(); 36 | return v; 37 | } 38 | if (arg.length === 1) { 39 | console.group(`W: state.${name}(${arg[0]})`); 40 | sig(arg[0]); 41 | console.groupEnd(); 42 | return; 43 | } 44 | throw '>1 signal argument'; 45 | }, 46 | }); 47 | 48 | const stateProx = {} as typeof state; 49 | let k: keyof typeof state; 50 | for (k in state) stateProx[k] = new Proxy(state[k], handlerFor(k)); 51 | 52 | const w = wire(function effect($) { 53 | console.log('Effect: count is', state.count($)); 54 | // state.countPlusOne($); 55 | // state.countPlusTwo($); 56 | }); 57 | w(); 58 | 59 | stateProx.count(1); 60 | stateProx.count(2); 61 | stateProx.count(3); 62 | stateProx.countPlusTwo(); 63 | 64 | console.log('count wires', [...state.count.wires].map((x) => x.name)); 65 | 66 | console.log('cPO wires', [...state.countPlusOne.wires].map((x) => x.name)); 67 | console.log('cPO cw.sigRS', [...(state.countPlusOne.cw as Wire).sigRS].map((x) => x.name)); 68 | 69 | console.log('cPT wires', [...state.countPlusTwo.wires].map((x) => x.name)); 70 | console.log('cPT cw.sigRS', [...(state.countPlusTwo.cw as Wire).sigRS].map((x) => x.name)); 71 | 72 | // console.log('Turn effect wire off'); 73 | // wireReset(w); 74 | 75 | stateProx.count(4); 76 | stateProx.countPlusTwo(); 77 | stateProx.countPlusOne(); 78 | stateProx.countPlusTwo(); 79 | -------------------------------------------------------------------------------- /test/haptic-stdlib.test.ts: -------------------------------------------------------------------------------- 1 | console.log('Test haptic-stdlib.test.ts'); 2 | -------------------------------------------------------------------------------- /test/haptic.test.ts: -------------------------------------------------------------------------------- 1 | console.log('Test haptic.test.ts'); 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "strict": true, 9 | "noUncheckedIndexedAccess": true, 10 | "outDir": "prepublish", 11 | "jsx": "preserve", 12 | "jsxFactory": "h", 13 | "importsNotUsedAsValues": "error", 14 | "baseUrl": ".", 15 | "paths": { 16 | "haptic": [ 17 | "./src/index.ts" 18 | ], 19 | "haptic/dom": [ 20 | "./src/dom/index.ts" 21 | ], 22 | "haptic/state": [ 23 | "./src/state/index.ts" 24 | ], 25 | "haptic/stdlib": [ 26 | "./src/stdlib/index.ts" 27 | ] 28 | } 29 | }, 30 | "include": [ 31 | "src", 32 | "test", 33 | "build.ts", 34 | "test.ts", 35 | ] 36 | } 37 | --------------------------------------------------------------------------------