├── .all-contributorsrc ├── .eslintrc.js ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── examples ├── fetch │ ├── .npmignore │ ├── Cup.tsx │ ├── README.md │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json ├── form │ ├── README.md │ ├── fakeForm.ts │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json └── timer │ ├── .npmignore │ ├── README.md │ ├── formatTime.ts │ ├── index.css │ ├── index.html │ ├── index.tsx │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── extras.ts ├── index.ts └── types.ts ├── test ├── index.test.ts └── types.twoslash-test.ts ├── tsconfig.json ├── twoslash-tester └── generate.js └── website ├── .gitignore ├── README.md ├── babel.config.js ├── docs ├── api.md ├── getting-started.md └── upgrading-from-0xx.md ├── docusaurus.config.js ├── package.json ├── src ├── css │ └── custom.css ├── pages │ ├── index.js │ └── index.module.css └── theme │ └── SearchBar │ ├── algolia.css │ ├── index.js │ ├── lib │ ├── DocSearch.js │ ├── lunar-search.js │ ├── templates.js │ └── utils.js │ └── styles.css ├── static ├── .nojekyll └── img │ ├── battery.svg │ ├── favicon.svg │ ├── logo.png │ ├── react.svg │ └── typescript.svg └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "cassiozen", 10 | "name": "Cassio Zen", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/33676?v=4", 12 | "profile": "http://YouTube.com/ReactCasts", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "test", 17 | "ideas", 18 | "bug" 19 | ] 20 | }, 21 | { 22 | "login": "devanshj", 23 | "name": "Devansh Jethmalani", 24 | "avatar_url": "https://avatars.githubusercontent.com/u/30295578?v=4", 25 | "profile": "https://github.com/devanshj", 26 | "contributions": [ 27 | "code", 28 | "test" 29 | "ideas", 30 | "bug", 31 | ] 32 | }, 33 | { 34 | "login": "RunDevelopment", 35 | "name": "Michael Schmidt", 36 | "avatar_url": "https://avatars.githubusercontent.com/u/20878432?v=4", 37 | "profile": "https://github.com/RunDevelopment", 38 | "contributions": [ 39 | "code", 40 | "test", 41 | "ideas" 42 | ] 43 | }, 44 | { 45 | "login": "icyJoseph", 46 | "name": "Joseph", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/21013447?v=4", 48 | "profile": "https://icyjoseph.dev/", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "mutewinter", 55 | "name": "Jeremy Mack", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/305901?v=4", 57 | "profile": "https://github.com/mutewinter", 58 | "contributions": [ 59 | "doc" 60 | ] 61 | }, 62 | { 63 | "login": "devronhansen", 64 | "name": "Ron", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/20226404?v=4", 66 | "profile": "https://github.com/devronhansen", 67 | "contributions": [ 68 | "doc" 69 | ] 70 | }, 71 | { 72 | "login": "klausbreyer", 73 | "name": "Klaus Breyer", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/32771?v=4", 75 | "profile": "https://v01.io", 76 | "contributions": [ 77 | "doc" 78 | ] 79 | }, 80 | { 81 | "login": "arthurdenner", 82 | "name": "Arthur Denner", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/13774309?v=4", 84 | "profile": "https://linktr.ee/arthurdenner", 85 | "contributions": [ 86 | "code", 87 | "bug", 88 | "test", 89 | "ideas" 90 | ] 91 | } 92 | ], 93 | "contributorsPerLine": 5, 94 | "projectName": "useStateMachine", 95 | "projectOwner": "cassiozen", 96 | "repoType": "github", 97 | "repoHost": "https://github.com", 98 | "skipCi": true 99 | } 100 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app', 'prettier/@typescript-eslint'], 3 | settings: { 4 | react: { 5 | version: 'detect', 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ["14.x"] 11 | os: [ubuntu-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Test 26 | run: npm test --ci --coverage --maxWorkers=2 27 | 28 | - name: Build 29 | run: npm run build 30 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .parcel-cache 7 | test/types.test.ts 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/types.ts 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Cassio Zen 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | **The <1 kb _state machine_ hook for React:** 6 | 7 | See the user-facing docs at: [usestatemachine.js.org](https://usestatemachine.js.org/) 8 | 9 | - Batteries Included: Despite the tiny size, useStateMachine is feature complete (Entry/exit callbacks, Guarded transitions & Extended State - Context) 10 | - Amazing TypeScript experience: Focus on automatic type inference (auto completion for both TypeScript & JavaScript users without having to manually define the typings) while giving you the option to specify and augment the types for context & events. 11 | - Made for React: useStateMachine follow idiomatic React patterns you and your team are already familiar with. (The library itself is actually a thin wrapper around React's useReducer & useEffect.) 12 | 13 | size badge 14 | 15 | 16 | ## Examples 17 | 18 | - State-driven UI (Hiding and showing UI Elements based on the state) - [CodeSandbox](https://codesandbox.io/s/github/cassiozen/usestatemachine/tree/main/examples/timer?file=/index.tsx) - [Source](./examples/timer) 19 | - Async orchestration (Fetch data with limited retry) - [CodeSandbox](https://codesandbox.io/s/github/cassiozen/usestatemachine/tree/main/examples/fetch?file=/index.tsx) - [Source](./examples/fetch) 20 | - Sending data with events (Form) - [CodeSandbox](https://codesandbox.io/s/github/cassiozen/usestatemachine/tree/main/examples/form?file=/index.tsx) - [Source](./examples/form) 21 | 22 | ## Installation 23 | 24 | ```bash 25 | npm install @cassiozen/usestatemachine 26 | ``` 27 | 28 | ## Sample Usage 29 | 30 | ```typescript 31 | import useStateMachine from "@cassiozen/usestatemachine"; 32 | 33 | const [state, send] = useStateMachine({ 34 | initial: 'inactive', 35 | states: { 36 | inactive: { 37 | on: { TOGGLE: 'active' }, 38 | }, 39 | active: { 40 | on: { TOGGLE: 'inactive' }, 41 | effect() { 42 | console.log('Just entered the Active state'); 43 | // Same cleanup pattern as `useEffect`: 44 | // If you return a function, it will run when exiting the state. 45 | return () => console.log('Just Left the Active state'); 46 | }, 47 | }, 48 | }, 49 | }); 50 | 51 | console.log(state); // { value: 'inactive', nextEvents: ['TOGGLE'] } 52 | 53 | // Refers to the TOGGLE event name for the state we are currently in. 54 | 55 | send('TOGGLE'); 56 | 57 | // Logs: Just entered the Active state 58 | 59 | console.log(state); // { value: 'active', nextEvents: ['TOGGLE'] } 60 | ``` 61 | 62 | 63 | # API 64 | 65 | # useStateMachine 66 | 67 | ```typescript 68 | const [state, send] = useStateMachine(/* State Machine Definition */); 69 | ``` 70 | 71 | `useStateMachine` takes a JavaScript object as the state machine definition. It returns an array consisting of a `current machine state` object and a `send` function to trigger transitions. 72 | 73 | ### state 74 | 75 | The machine's `state` consists of 4 properties: `value`, `event`, `nextEvents` and `context`. 76 | 77 | `value` (string): Returns the name of the current state. 78 | 79 | `event` (`{type: string}`; Optional): The name of the last sent event that led to this state. 80 | 81 | `nextEvents` (`string[]`): An array with the names of available events to trigger transitions from this state. 82 | 83 | `context`: The state machine extended state. See "Extended State" below. 84 | 85 | ### Send events 86 | 87 | `send` takes an event as argument, provided in shorthand string format (e.g. "TOGGLE") or as an event object (e.g. `{ type: "TOGGLE" }`) 88 | 89 | If the current state accepts this event, and it is allowed (see guard), it will change the state machine state and execute effects. 90 | 91 | You can also send additional data with your event using the object notation (e.g. `{ type: "UPDATE" value: 10 }`). Check [schema](#schema-context--event-typing) for more information about strong typing the additional data. 92 | 93 | # State Machine definition 94 | 95 | | Key | Required | Description | 96 | | ----------- | ---- |----------- | 97 | | verbose | | If true, will log every context & state changes. Log messages will be stripped out in the production build. | 98 | | schema | | For usage with TypeScript only. Optional strongly-typed context & events. More on schema [below](#schema-context--event-typing) | 99 | | context | | Context is the machine's extended state. More on extended state [below](#extended-state-context) | 100 | | initial | * | The initial state node this machine should be in | 101 | | states | * | Define the possible finite states the state machine can be in. | 102 | 103 | ## Defining States 104 | 105 | A finite state machine can be in only one of a finite number of states at any given time. As an application is interacted with, events cause it to change state. 106 | 107 | States are defined with the state name as a key and an object with two possible keys: `on` (which events this state responds to) and `effect` (run arbitrary code when entering or exiting this state): 108 | 109 | ### On (Events & transitions) 110 | 111 | Describes which events this state responds to (and to which other state the machine should transition to when this event is sent): 112 | 113 | ```typescript 114 | states: { 115 | inactive: { 116 | on: { 117 | TOGGLE: 'active'; 118 | } 119 | }, 120 | active: { 121 | on: { 122 | TOGGLE: 'inactive'; 123 | } 124 | }, 125 | }, 126 | ``` 127 | 128 | The event definition can also use the extended, object syntax, which allows for more control over the transition (like adding guards): 129 | 130 | ```js 131 | on: { 132 | TOGGLE: { 133 | target: 'active', 134 | }, 135 | }; 136 | ``` 137 | 138 | #### Guards 139 | 140 | Guards are functions that run before actually making the state transition: If the guard returns false the transition will be denied. 141 | 142 | ```js 143 | const [state, send] = useStateMachine({ 144 | initial: 'inactive', 145 | states: { 146 | inactive: { 147 | on: { 148 | TOGGLE: { 149 | target: 'active', 150 | guard({ context, event }) { 151 | // Return a boolean to allow or block the transition 152 | }, 153 | }, 154 | }, 155 | }, 156 | active: { 157 | on: { TOGGLE: 'inactive' }, 158 | }, 159 | }, 160 | }); 161 | ``` 162 | 163 | The guard function receives an object with the current context and the event. The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`). 164 | 165 | ### Effects (entry/exit callbacks) 166 | 167 | Effects are triggered when the state machine enters a given state. If you return a function from your effect, it will be invoked when leaving that state (similarly to how useEffect works in React). 168 | 169 | ```typescript 170 | const [state, send] = useStateMachine({ 171 | initial: 'active', 172 | states: { 173 | active: { 174 | on: { TOGGLE: 'inactive' }, 175 | effect({ send, setContext, event, context }) { 176 | console.log('Just entered the Active state'); 177 | return () => console.log('Just Left the Active state'); 178 | }, 179 | }, 180 | }, 181 | }); 182 | ``` 183 | 184 | The effect function receives an object as parameter with four keys: 185 | 186 | - `send`: Takes an event as argument, provided in shorthand string format (e.g. "TOGGLE") or as an event object (e.g. `{ type: "TOGGLE" }`) 187 | - `setContext`: Takes an updater function as parameter to set a new context (more on context below). Returns an object with `send`, so you can set the context and send an event on a single line. 188 | - `event`: The event that triggered a transition to this state. (The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`).). 189 | - `context` The context at the time the effect runs. 190 | 191 | In this example, the state machine will always send the "RETRY" event when entering the error state: 192 | 193 | ```typescript 194 | const [state, send] = useStateMachine({ 195 | initial: 'loading', 196 | states: { 197 | /* Other states here... */ 198 | error: { 199 | on: { 200 | RETRY: 'load', 201 | }, 202 | effect({ send }) { 203 | send('RETRY'); 204 | }, 205 | }, 206 | }, 207 | }); 208 | ``` 209 | 210 | ## Extended state (context) 211 | 212 | Besides the finite number of states, the state machine can have extended state (known as context). 213 | 214 | You can provide the initial context value in the state machine definition, then use the `setContext` function within your effects to change the context: 215 | 216 | ```js 217 | const [state, send] = useStateMachine({ 218 | context: { toggleCount: 0 }, 219 | initial: 'inactive', 220 | states: { 221 | inactive: { 222 | on: { TOGGLE: 'active' }, 223 | }, 224 | active: { 225 | on: { TOGGLE: 'inactive' }, 226 | effect({ setContext }) { 227 | setContext(context => ({ toggleCount: context.toggleCount + 1 })); 228 | }, 229 | }, 230 | }, 231 | }); 232 | 233 | console.log(state); // { context: { toggleCount: 0 }, value: 'inactive', nextEvents: ['TOGGLE'] } 234 | 235 | send('TOGGLE'); 236 | 237 | console.log(state); // { context: { toggleCount: 1 }, value: 'active', nextEvents: ['TOGGLE'] } 238 | ``` 239 | 240 | ## Schema: Context & Event Typing 241 | 242 | TypeScript will automatically infer your context type; event types are generated automatically. 243 | 244 | Still, there are situations where you might want explicit control over the `context` and `event` types: You can provide you own typing using the `t` whithin `schema`: 245 | 246 | *Typed Context example* 247 | 248 | ```typescript 249 | const [state, send] = useStateMachine({ 250 | schema: { 251 | context: t<{ toggleCount: number }>() 252 | }, 253 | context: { toggleCount: 0 }, 254 | initial: 'inactive', 255 | states: { 256 | inactive: { 257 | on: { TOGGLE: 'active' }, 258 | }, 259 | active: { 260 | on: { TOGGLE: 'inactive' }, 261 | effect({ setContext }) { 262 | setContext(context => ({ toggleCount: context.toggleCount + 1 })); 263 | }, 264 | }, 265 | }, 266 | }); 267 | ``` 268 | 269 | *Typed Events* 270 | 271 | 272 | All events are type-infered by default, both in the string notation (`send("UPDATE")`) and the object notation (`send({ type: "UPDATE"})`). 273 | 274 | If you want, though, you can augment an already typed event to include arbitrary data (which can be useful to provide values to be used inside effects or to update the context). Example: 275 | 276 | ```typescript 277 | const [machine, send] = useStateMachine({ 278 | schema: { 279 | context: t<{ timeout?: number }>(), 280 | events: { 281 | PING: t<{ value: number }>() 282 | } 283 | }, 284 | context: {timeout: undefined}, 285 | initial: 'waiting', 286 | states: { 287 | waiting: { 288 | on: { 289 | PING: 'pinged' 290 | } 291 | }, 292 | pinged: { 293 | effect({ setContext, event }) { 294 | setContext(c => ({ timeout: event?.value ?? 0 })); 295 | }, 296 | } 297 | }, 298 | }); 299 | 300 | send({ type: 'PING', value: 150 }) 301 | ``` 302 | 303 | **Note** that you don't need to declare all your events in the schema, only the ones you're adding arbitrary keys and values. 304 | 305 | 306 | # Wiki 307 | 308 | - [Updating from version 0.x.x to 1.0](https://github.com/cassiozen/useStateMachine/wiki/Updating-from-0.X.X-to-1.0.0) 309 | - [Contributing](https://github.com/cassiozen/useStateMachine/wiki/Contributing-to-useStateMachine) 310 | - [Comparison with XState](https://github.com/cassiozen/useStateMachine/wiki/XState-comparison) 311 | - [Source code walkthrough video](https://github.com/cassiozen/useStateMachine/wiki/Source-code-walkthrough-video) 312 | - [Usage with Preact](https://github.com/cassiozen/useStateMachine/wiki/Usage-with-Preact) 313 | 314 | # Contributors ✨ 315 | 316 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 |

Cassio Zen

💻 📖 ⚠️ 🤔 🐛

Devansh Jethmalani

💻 ⚠️ 🤔 🐛

Michael Schmidt

💻 ⚠️ 🤔

Joseph

💻

Jeremy Mack

📖

Ron

📖

Klaus Breyer

📖

Arthur Denner

💻 🐛 ⚠️ 🤔
336 | 337 | 338 | 339 | 340 | 341 | 342 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 343 | -------------------------------------------------------------------------------- /examples/fetch/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /examples/fetch/Cup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function Cup() { 4 | return ( 5 | 6 | 12 | 18 | 24 | 25 | ); 26 | } 27 | 28 | export default Cup; 29 | -------------------------------------------------------------------------------- /examples/fetch/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | To run this example: 4 | 5 | - `npm install` or `yarn` 6 | - `npm start` or `yarn start` 7 | 8 | ## Local Development 9 | 10 | For local development, you'll have to update `package.json` and `tsconfig.json`: 11 | 12 | **package.json** 13 | 14 | 1. Remove everything but "react-dom" from `dependencies` 15 | 16 | 2. Add an `alias` section pointing to usestatemachine in the dist folder and React from the main node_modules. 17 | 18 | ```json 19 | "alias": { 20 | "react": "../../node_modules/react", 21 | "scheduler/tracing": "../../node_modules/scheduler/tracing-profiling", 22 | "@cassiozen/usestatemachine": "../../dist" 23 | }, 24 | ``` 25 | 26 | **tsconfig.json** 27 | 28 | 1. Add a `paths` section pointing to usestatemachine in the dist folder. 29 | 30 | ```json 31 | "paths": { 32 | "@cassiozen/usestatemachine": ["../../dist/index"], 33 | }, 34 | ``` 35 | 36 | 37 | Finally, run `npm start` on both the root library and on this example folder. -------------------------------------------------------------------------------- /examples/fetch/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background-color: #233b35; 7 | color: #ededed; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 9 | 'Droid Sans', 'Helvetica Neue', sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | font-family: 'Bad Script', cursive; 13 | } 14 | 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6 { 21 | font-family: 'Fredericka the Great', cursive; 22 | margin-bottom: 0; 23 | } 24 | 25 | p { 26 | margin: 0; 27 | } 28 | 29 | svg { 30 | display: block; 31 | margin: auto; 32 | margin-bottom: 3em; 33 | } 34 | 35 | ul { 36 | columns: 2; 37 | padding: 0; 38 | } 39 | li { 40 | break-inside: avoid; 41 | list-style: none; 42 | } 43 | 44 | .coffees { 45 | padding: 1em 8em; 46 | } 47 | 48 | @media only screen and (max-width: 400px) { 49 | ul { 50 | columns: 1; 51 | } 52 | } 53 | 54 | @media only screen and (max-width: 700px) { 55 | .coffees { 56 | padding: 1em; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/fetch/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | Fetch with Retry 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/fetch/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 4 | import './index.css'; 5 | import Cup from './Cup'; 6 | 7 | /* 8 | * In this example we're fetching some data with included retry logic (Will retry 2 times before giving up) 9 | */ 10 | 11 | type Coffee = { 12 | id: number; 13 | title: string; 14 | description: string; 15 | ingredients: string[]; 16 | }; 17 | 18 | function App() { 19 | const [machine, send] = useStateMachine({ 20 | schema: { 21 | context: t<{ retryCount: number; data?: Coffee[]; error?: string }>() 22 | }, 23 | context: { retryCount: 0 }, 24 | initial: 'loading', 25 | verbose: true, 26 | states: { 27 | loading: { 28 | on: { 29 | SUCCESS: 'loaded', 30 | FAILURE: 'error', 31 | }, 32 | effect({ setContext }) { 33 | const fetchCoffees = async () => { 34 | let response: Response; 35 | try { 36 | response = await fetch('https://api.sampleapis.com/coffee/hot'); 37 | if (!response.ok) { 38 | throw new Error(`An error has occured: ${response.status}`); 39 | } 40 | const coffees = await response.json(); 41 | setContext(context => ({ data: coffees, ...context })).send('SUCCESS'); 42 | } catch (error) { 43 | setContext(context => ({ error: error.message, ...context })).send('FAILURE'); 44 | } 45 | }; 46 | fetchCoffees(); 47 | }, 48 | }, 49 | loaded: {}, 50 | error: { 51 | on: { 52 | RETRY: { 53 | target: 'loading', 54 | guard: ({ context }) => context.retryCount < 3, 55 | }, 56 | }, 57 | effect({ setContext }) { 58 | setContext(context => ({ ...context, retryCount: context.retryCount + 1 })).send('RETRY'); 59 | }, 60 | }, 61 | }, 62 | }); 63 | 64 | return ( 65 |
66 | 67 | {machine.value === 'loading' &&

Loading

} 68 | {machine.value === 'error' &&

{machine.context.error}

} 69 | {machine.value === 'loaded' && ( 70 | 78 | )} 79 |
80 | ); 81 | } 82 | 83 | ReactDOM.render(, document.getElementById('root')); 84 | -------------------------------------------------------------------------------- /examples/fetch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "@cassiozen/usestatemachine": "^1.0.1", 12 | "react": "^17.0.1", 13 | "react-dom": "^17.0.1" 14 | }, 15 | "devDependencies": { 16 | "@babel/preset-env": "7.13.8", 17 | "@types/react": "^16.9.11", 18 | "@types/react-dom": "^16.8.4", 19 | "parcel": "1.12.4", 20 | "typescript": "^4.3.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/fetch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/form/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | To run this example: 4 | 5 | - `npm install` or `yarn` 6 | - `npm start` or `yarn start` 7 | 8 | ## Local Development 9 | 10 | For local development, you'll have to update `package.json` and `tsconfig.json`: 11 | 12 | **package.json** 13 | 14 | 1. Remove everything but "react-dom" from `dependencies` 15 | 16 | 2. Add an `alias` section pointing to usestatemachine in the dist folder and React from the main node_modules. 17 | 18 | ```json 19 | "alias": { 20 | "react": "../../node_modules/react", 21 | "scheduler/tracing": "../../node_modules/scheduler/tracing-profiling", 22 | "@cassiozen/usestatemachine": "../../dist" 23 | }, 24 | ``` 25 | 26 | **tsconfig.json** 27 | 28 | 1. Add a `paths` section pointing to usestatemachine in the dist folder. 29 | 30 | ```json 31 | "paths": { 32 | "@cassiozen/usestatemachine": ["../../dist/index"], 33 | }, 34 | ``` 35 | 36 | 37 | Finally, run `npm start` on both the root library and on this example folder. -------------------------------------------------------------------------------- /examples/form/fakeForm.ts: -------------------------------------------------------------------------------- 1 | export function checkUsernameAvailability(username: string) { 2 | return new Promise((res, rej) => { 3 | setTimeout(() => { 4 | let sum = 0; 5 | for (let i = 0; i < username.length; i++) { 6 | sum += username.toLowerCase().charCodeAt(i); 7 | } 8 | // Fake but consistently rejects some usernames as if they're already taken. 9 | res(sum > 300 && sum % 4 !== 0); 10 | }, Math.random() * 500); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /examples/form/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background-color: #1d1f23; 7 | color: #ededed; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 9 | 'Droid Sans', 'Helvetica Neue', sans-serif; 10 | font-size: 16px; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | zoom: 115%; 14 | } 15 | 16 | .loader { 17 | display: inline-block; 18 | border: 0.2em solid #f3f3f3; /* Light grey */ 19 | border-top: 0.2em solid #555; /* Blue */ 20 | border-radius: 50%; 21 | width: 1em; 22 | height: 1em; 23 | animation: spin 2s linear infinite; 24 | } 25 | 26 | @keyframes spin { 27 | 0% { 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | 35 | .usernameForm form { 36 | display: flex; 37 | justify-content: space-between; 38 | align-items: center; 39 | width: 18em; 40 | } 41 | 42 | .usernameForm input { 43 | font-size: 0.85em; 44 | padding: 0.26em; 45 | } 46 | -------------------------------------------------------------------------------- /examples/form/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Form 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/form/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 4 | import { checkUsernameAvailability } from './fakeForm'; 5 | import './index.css'; 6 | 7 | /* 8 | * In this example we use events with payload to send data from the form to the state machine 9 | */ 10 | 11 | function App() { 12 | const [machine, send] = useStateMachine({ 13 | schema: { 14 | context: t<{ input: string }>(), 15 | events: { 16 | UPDATE: t<{ value: string }>() 17 | } 18 | }, 19 | context: {input: ''}, 20 | verbose: true, 21 | initial: 'pristine', 22 | states: { 23 | pristine: {}, 24 | editing: { 25 | on: { 26 | VALIDATE: 'validating', 27 | }, 28 | effect({ send, setContext, event }) { 29 | setContext(c => ({ input: event?.value ?? '' })); 30 | const timeout = setTimeout(() => { 31 | send({ type: 'VALIDATE' }); 32 | }, 300); 33 | return () => clearTimeout(timeout); 34 | }, 35 | }, 36 | validating: { 37 | on: { 38 | VALID: 'valid', 39 | INVALID: 'invalid', 40 | }, 41 | effect({ send, context }) { 42 | checkUsernameAvailability(context.input).then(usernameAvailable => { 43 | if (usernameAvailable) send('VALID'); 44 | else send('INVALID'); 45 | }); 46 | }, 47 | }, 48 | valid: {}, 49 | invalid: {}, 50 | }, 51 | on: { 52 | UPDATE: 'editing', 53 | }, 54 | }); 55 | 56 | return ( 57 |
58 |
59 | send({ type: 'UPDATE', value: e.target.value })} 65 | /> 66 | {machine.value === 'validating' &&
} 67 | {machine.value === 'valid' && '✔'} 68 | {machine.value === 'invalid' && '❌'} 69 | 72 | 73 |
74 | ); 75 | } 76 | 77 | ReactDOM.render(, document.getElementById('root')); 78 | -------------------------------------------------------------------------------- /examples/form/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.1", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "@cassiozen/usestatemachine": "^1.0.1", 12 | "react": "^17.0.1", 13 | "react-dom": "^17.0.1" 14 | }, 15 | "devDependencies": { 16 | "@babel/preset-env": "7.13.8", 17 | "@types/react": "^16.9.11", 18 | "@types/react-dom": "^16.8.4", 19 | "parcel": "1.12.4", 20 | "typescript": "^4.3.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/form/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/timer/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /examples/timer/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | To run this example: 4 | 5 | - `npm install` or `yarn` 6 | - `npm start` or `yarn start` 7 | 8 | ## Local Development 9 | 10 | For local development, you'll have to update `package.json` and `tsconfig.json`: 11 | 12 | **package.json** 13 | 14 | 1. Remove everything but "react-dom" from `dependencies` 15 | 16 | 2. Add an `alias` section pointing to usestatemachine in the dist folder and React from the main node_modules. 17 | 18 | ```json 19 | "alias": { 20 | "react": "../../node_modules/react", 21 | "scheduler/tracing": "../../node_modules/scheduler/tracing-profiling", 22 | "@cassiozen/usestatemachine": "../../dist" 23 | }, 24 | ``` 25 | 26 | **tsconfig.json** 27 | 28 | 1. Add a `paths` section pointing to usestatemachine in the dist folder. 29 | 30 | ```json 31 | "paths": { 32 | "@cassiozen/usestatemachine": ["../../dist/index"], 33 | }, 34 | ``` 35 | 36 | 37 | Finally, run `npm start` on both the root library and on this example folder. -------------------------------------------------------------------------------- /examples/timer/formatTime.ts: -------------------------------------------------------------------------------- 1 | export default function formatTime(time: number) { 2 | const mins = Math.floor(time / 600); 3 | const secs = Math.floor(time / 10) % 60; 4 | const ms = Math.floor(time % 10); 5 | 6 | if (secs < 10) return `${mins}:0${secs}.${ms}`; 7 | 8 | return `${mins}:${secs}.${ms}`; 9 | } 10 | -------------------------------------------------------------------------------- /examples/timer/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background-color: #1d1f23; 7 | color: #ededed; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 9 | 'Droid Sans', 'Helvetica Neue', sans-serif; 10 | font-size: 24px; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | .StopWatch { 16 | width: 10em; 17 | } 18 | 19 | .display { 20 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 21 | font-size: 2em; 22 | text-align: center; 23 | } 24 | 25 | .controls { 26 | display: flex; 27 | justify-content: space-around; 28 | margin-top: 1em; 29 | } 30 | 31 | button { 32 | position: relative; 33 | width: 5em; 34 | height: 5em; 35 | border-radius: 100%; 36 | background-color: #383b4c; 37 | color: #fff; 38 | border: none; 39 | outline: none; 40 | } 41 | button:focus { 42 | font-weight: bold; 43 | } 44 | button:after { 45 | content: ''; 46 | position: absolute; 47 | border-radius: 100%; 48 | top: -5px; 49 | left: -5px; 50 | right: -5px; 51 | bottom: -5px; 52 | border: #383b4c 2px solid; 53 | } 54 | -------------------------------------------------------------------------------- /examples/timer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Timer 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/timer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 4 | import './index.css'; 5 | import formatTime from './formatTime'; 6 | 7 | /* 8 | * In this example we simulate a somewhat complicated UI: 9 | * there are multiple buttons but they can only appear when they can be used 10 | */ 11 | 12 | function App() { 13 | const [machine, send] = useStateMachine({ 14 | schema: { 15 | context: t<{time: number}>(), 16 | }, 17 | context: {time: 0}, 18 | initial: 'idle', 19 | verbose: true, 20 | states: { 21 | idle: { 22 | on: { 23 | START: { 24 | target: 'running', 25 | }, 26 | }, 27 | effect({ setContext }) { 28 | setContext(() => ({ time: 0 })); 29 | }, 30 | }, 31 | running: { 32 | on: { 33 | PAUSE: 'paused', 34 | }, 35 | effect({ setContext }) { 36 | const interval = setInterval(() => { 37 | setContext(context => ({ time: context.time + 1 })); 38 | }, 100); 39 | return () => clearInterval(interval); 40 | }, 41 | }, 42 | paused: { 43 | on: { 44 | RESET: 'idle', 45 | START: { 46 | target: 'running', 47 | }, 48 | }, 49 | }, 50 | }, 51 | }); 52 | 53 | return ( 54 |
55 |
{formatTime(machine.context.time)}
56 | 57 |
58 | {machine.nextEvents?.includes('START') && ( 59 | 62 | )} 63 | 64 | {machine.nextEvents?.includes('PAUSE') && ( 65 | 68 | )} 69 | 70 | {machine.nextEvents?.includes('RESET') && ( 71 | 74 | )} 75 |
76 |
77 | ); 78 | } 79 | 80 | ReactDOM.render(, document.getElementById('root')); 81 | -------------------------------------------------------------------------------- /examples/timer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "@cassiozen/usestatemachine": "^1.0.1", 12 | "react": "^17.0.1", 13 | "react-dom": "^17.0.1" 14 | }, 15 | "devDependencies": { 16 | "@babel/preset-env": "7.13.8", 17 | "@types/react": "^16.9.11", 18 | "@types/react-dom": "^16.8.4", 19 | "parcel": "1.12.4", 20 | "typescript": "^4.3.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/timer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cassiozen/usestatemachine", 3 | "version": "1.0.1", 4 | "license": "MIT", 5 | "author": "Cassio Zen", 6 | "main": "dist/index.js", 7 | "module": "dist/usestatemachine.esm.js", 8 | "types": "dist/index.d.ts", 9 | "sideEffects": false, 10 | "files": [ 11 | "dist", 12 | "src" 13 | ], 14 | "engines": { 15 | "node": ">=12" 16 | }, 17 | "scripts": { 18 | "analyze": "size-limit --why", 19 | "build": "dts build", 20 | "lint": "dts lint", 21 | "prepare": "dts build", 22 | "size": "size-limit", 23 | "start": "dts watch", 24 | "test": "dts test" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16.8.0" 28 | }, 29 | "devDependencies": { 30 | "@size-limit/preset-small-lib": "^7.0.5", 31 | "@testing-library/react-hooks": "^5.1.0", 32 | "@tsconfig/create-react-app": "^1.0.2", 33 | "@tsconfig/recommended": "^1.0.1", 34 | "@types/jest": "^25.2.3", 35 | "@types/react": "^17.0.38", 36 | "@types/react-dom": "^17.0.11", 37 | "@typescript-eslint/eslint-plugin": "^4.28.4", 38 | "@typescript-eslint/parser": "^4.28.4", 39 | "@typescript/twoslash": "^2.1.0", 40 | "dts-cli": "^1.1.6", 41 | "eslint-plugin-react-hooks": "^4.2.0", 42 | "husky": "^7.0.4", 43 | "react": ">=16.8.0", 44 | "react-test-renderer": "^17.0.1", 45 | "size-limit": "^7.0.5", 46 | "ts-jest": "^27.0.4", 47 | "tsd": "^0.15.1", 48 | "tslib": "^2.3.1", 49 | "typescript": "^4.5.4" 50 | }, 51 | "husky": { 52 | "hooks": { 53 | "pre-commit": "dts lint" 54 | } 55 | }, 56 | "prettier": { 57 | "singleQuote": false, 58 | "printWidth": 120, 59 | "trailingComma": "es5" 60 | }, 61 | "jest": { 62 | "resetMocks": true 63 | }, 64 | "size-limit": [ 65 | { 66 | "path": "dist/usestatemachine.cjs.production.min.js", 67 | "limit": "1 kB", 68 | "brotli": true 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/extras.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | export const R = { 4 | get: (r: R, k: R.Key) => (r as any)[k] as R.Value | undefined, 5 | concat: (r1: R1, r2: R2) => 6 | (({ ...r1, ...r2 } as any) as R.Concat), 7 | fromMaybe: (r: R | undefined) => r ?? ({} as R), 8 | keys: (r: R) => Object.keys(r) as R.Key[] 9 | }; 10 | 11 | const $$K = Symbol("R.$$K"); 12 | const $$V = Symbol("R.$$V"); 13 | export namespace R { 14 | export type $$K = typeof $$K; 15 | export type $$V = typeof $$V; 16 | 17 | export type Of = { [$$K]: K, [$$V]: V }; 18 | export type Unknown = Of 19 | 20 | export type Key = R[$$K] 21 | export type Value = R[$$V]; 22 | export type Concat = R.Of< 23 | R.Key | R.Key, 24 | R.Value | R.Value 25 | >; 26 | } 27 | 28 | export const useConstant = (compute: () => T): T => { 29 | const ref = useRef(null); 30 | if (ref.current === null) ref.current = compute(); 31 | return ref.current; 32 | }; 33 | 34 | export const assertNever = (_value: never): never => { 35 | throw new Error("Invariant: assertNever was called"); 36 | }; 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from "react"; 2 | import { UseStateMachine, Machine, $$t } from "./types"; 3 | import { assertNever, R, useConstant } from "./extras"; 4 | 5 | const useStateMachineImpl = (definition: Machine.Definition.Impl) => { 6 | const [state, dispatch] = useReducer(createReducer(definition), createInitialState(definition)); 7 | 8 | const send = useConstant(() => (sendable: Machine.Sendable.Impl) => dispatch({ type: "SEND", sendable })); 9 | 10 | const setContext = (updater: Machine.ContextUpdater.Impl) => { 11 | dispatch({ type: "SET_CONTEXT", updater }); 12 | return { send }; 13 | }; 14 | 15 | useEffect(() => { 16 | const entry = R.get(definition.states, state.value)!.effect; 17 | let exit = entry?.({ 18 | send, 19 | setContext, 20 | event: state.event, 21 | context: state.context, 22 | }); 23 | 24 | return typeof exit === "function" 25 | ? () => exit?.({ send, setContext, event: state.event, context: state.context }) 26 | : undefined; 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | }, [state.value, state.event]); 29 | 30 | return [state, send]; 31 | }; 32 | 33 | const createInitialState = (definition: Machine.Definition.Impl): Machine.State.Impl => { 34 | let nextEvents = R.keys(R.concat( 35 | R.fromMaybe(R.get(definition.states, definition.initial)!.on), 36 | R.fromMaybe(definition.on) 37 | )) 38 | return { 39 | value: definition.initial, 40 | context: definition.context as Machine.Context.Impl, 41 | event: { type: "$$initial" } as Machine.Event.Impl, 42 | nextEvents: nextEvents, 43 | nextEventsT: nextEvents 44 | } 45 | } 46 | 47 | const createReducer = (definition: Machine.Definition.Impl) => { 48 | let log = createLogger(definition); 49 | return (machineState: Machine.State.Impl, internalEvent: InternalEvent): Machine.State.Impl => { 50 | if (internalEvent.type === "SET_CONTEXT") { 51 | let nextContext = internalEvent.updater(machineState.context); 52 | log("Context update", ["Previous Context", machineState.context], ["Next Context", nextContext]); 53 | 54 | return { ...machineState, context: nextContext }; 55 | } 56 | 57 | if (internalEvent.type === "SEND") { 58 | let sendable = internalEvent.sendable; 59 | let event = typeof sendable === "string" ? { type: sendable } : sendable; 60 | let context = machineState.context; 61 | let stateNode = R.get(definition.states, machineState.value)!; 62 | let resolvedTransition = 63 | R.get(R.fromMaybe(stateNode.on), event.type) ?? R.get(R.fromMaybe(definition.on), event.type); 64 | 65 | if (!resolvedTransition) { 66 | log( 67 | `Current state doesn't listen to event type "${event.type}".`, 68 | ["Current State", machineState], 69 | ["Event", event] 70 | ); 71 | return machineState; 72 | } 73 | 74 | let [nextStateValue, didGuardDeny = false] = (() => { 75 | if (typeof resolvedTransition === "string") return [resolvedTransition]; 76 | if (resolvedTransition.guard === undefined) return [resolvedTransition.target]; 77 | if (resolvedTransition.guard({ context, event })) return [resolvedTransition.target]; 78 | return [resolvedTransition.target, true] 79 | })() as [Machine.StateValue.Impl, true?] 80 | 81 | if (didGuardDeny) { 82 | log( 83 | `Transition from "${machineState.value}" to "${nextStateValue}" denied by guard`, 84 | ["Event", event], 85 | ["Context", context] 86 | ); 87 | return machineState; 88 | } 89 | log(`Transition from "${machineState.value}" to "${nextStateValue}"`, ["Event", event]); 90 | 91 | let resolvedStateNode = R.get(definition.states, nextStateValue)!; 92 | 93 | let nextEvents = R.keys(R.concat( 94 | R.fromMaybe(resolvedStateNode.on), 95 | R.fromMaybe(definition.on) 96 | )); 97 | return { 98 | value: nextStateValue, 99 | context, 100 | event, 101 | nextEvents, 102 | nextEventsT: nextEvents 103 | }; 104 | } 105 | 106 | return assertNever(internalEvent); 107 | }; 108 | }; 109 | 110 | interface SetContextEvent { 111 | type: "SET_CONTEXT"; 112 | updater: Machine.ContextUpdater.Impl; 113 | } 114 | 115 | interface SendEvent { 116 | type: "SEND"; 117 | sendable: Machine.Sendable.Impl; 118 | } 119 | 120 | type InternalEvent = SetContextEvent | SendEvent; 121 | 122 | export type Console = 123 | { log: (a: string, b: string | object) => void 124 | , groupCollapsed?: (...l: string[]) => void 125 | , groupEnd?: () => void 126 | } 127 | 128 | // test sub-typing 129 | const defaultConsole: Console = console 130 | 131 | const createLogger = (definition: Machine.Definition.Impl) => (groupLabel: string, ...nested: [string, string | object][]) => { 132 | if (!definition.verbose) return; 133 | 134 | let console = definition.console || defaultConsole 135 | if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { 136 | console.groupCollapsed?.("%cuseStateMachine", "color: #888; font-weight: lighter;", groupLabel); 137 | nested.forEach(message => { 138 | console.log(message[0], message[1]); 139 | }); 140 | console.groupEnd?.(); 141 | } 142 | }; 143 | 144 | const useStateMachine = useStateMachineImpl as unknown as UseStateMachine; 145 | export default useStateMachine; 146 | 147 | export const t = () => ({ [$$t]: undefined as T }) 148 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Console } from "." 2 | import { R } from "./extras" 3 | 4 | export type UseStateMachine = 5 | >(definition: A.InferNarrowestObject) => 6 | [ state: Machine.State> 7 | , send: Machine.Send> 8 | ] 9 | 10 | export const $$t = Symbol("$$t"); 11 | type $$t = typeof $$t; 12 | export type CreateType = 13 | () => { [$$t]: T } 14 | 15 | export namespace Machine { 16 | export type Definition< 17 | Self, 18 | States = A.Get, 19 | ContextSchema = A.Get, 20 | HasContextSchema = Self extends { schema: { context: unknown } } ? true : false 21 | > = 22 | & { initial: 23 | A.IsUnknown extends true 24 | ? LS.ConcatAll< 25 | [ "Oops you have met a TypeScript limitation, " 26 | , "please add `on: {}` to state nodes that only have an `effect` property. " 27 | , "See the documentation to learn more." 28 | ]> : 29 | [keyof States] extends [never] 30 | ? A.CustomError<"Error: no states defined", A.Get> 31 | : keyof States 32 | , states: 33 | { [StateIdentifier in keyof States]: 34 | StateIdentifier extends A.String 35 | ? Definition.StateNode 36 | : A.CustomError<"Error: Only string identifiers allowed", States[StateIdentifier]> 37 | } 38 | , on?: Definition.On 39 | , schema?: Definition.Schema 40 | , verbose?: boolean 41 | , console?: Console 42 | , $$internalIsConstraint?: 43 | A.CustomError< 44 | "Error: Ignore, it's for internal types usage", 45 | A.Get 46 | > 47 | } 48 | & ( ContextSchema extends undefined 49 | ? HasContextSchema extends true 50 | ? { context?: undefined } 51 | : { context?: unknown } 52 | : { context: ContextSchema } 53 | ) 54 | 55 | interface DefinitionImp 56 | { initial: StateValue.Impl 57 | , states: R.Of 58 | , on?: Definition.On.Impl 59 | , schema?: { context?: null, events?: R.Of } 60 | , verbose?: boolean 61 | , console?: Console 62 | , context?: Context.Impl 63 | } 64 | 65 | export namespace Definition { 66 | export type Impl = DefinitionImp 67 | 68 | export type FromTypeParamter = 69 | "$$internalIsConstraint" extends keyof D 70 | ? D extends infer X ? X extends Definition ? X : never : never 71 | : D 72 | 73 | export interface StateNode 74 | { on?: On> 75 | , effect?: Effect> 76 | } 77 | 78 | interface StateNodeImpl 79 | { on?: On.Impl 80 | , effect?: Effect.Impl 81 | } 82 | export namespace StateNode { 83 | export type Impl = StateNodeImpl 84 | } 85 | 86 | export type On< 87 | D, P, Self = A.Get, 88 | EventsSchema = A.Get, 89 | EventTypeConstraint = 90 | A.Get extends true 91 | ? U.Exclude 92 | : A.String 93 | > = 94 | { [EventType in keyof Self]: 95 | A.DoesExtend extends false 96 | ? A.CustomError<"Error: only string types allowed", A.Get> : 97 | EventType extends ExhaustiveIdentifier 98 | ? A.CustomError< 99 | `Error: '${ExhaustiveIdentifier}' is a reserved name`, 100 | A.Get 101 | > : 102 | EventType extends InitialEventType 103 | ? A.CustomError< 104 | `Error: '${InitialEventType}' is a reserved type`, 105 | A.Get 106 | > : 107 | A.DoesExtend extends false 108 | ? A.CustomError< 109 | LS.ConcatAll< 110 | [ `Error: Event type '${S.Assert}' is not found in schema.events ` 111 | , "which is marked as exhaustive" 112 | ]>, 113 | A.Get 114 | > : 115 | Transition> 116 | } 117 | 118 | type OnImpl = R.Of 119 | export namespace On { 120 | export type Impl = OnImpl; 121 | } 122 | 123 | export type Transition, 125 | Event = { type: L.Pop

} 126 | > = 127 | | TargetString 128 | | { target: TargetString 129 | , guard?: 130 | ( parameter: 131 | { context: Machine.Context 132 | , event: U.Extract, Event> 133 | } 134 | ) => boolean 135 | } 136 | 137 | type TransitionImpl = 138 | | State.Impl["value"] 139 | | { target: State.Impl["value"] 140 | , guard?: 141 | ( parameter: 142 | { context: State.Impl["context"] 143 | , event: State.Impl["event"] 144 | } 145 | ) => boolean 146 | } 147 | export namespace Transition { 148 | export type Impl = TransitionImpl 149 | } 150 | 151 | 152 | export type Effect>> = 153 | (parameter: EffectParameterForStateValue) => 154 | | void 155 | | ((parameter: EffectCleanupParameterForStateValue) => void) 156 | 157 | type EffectImpl = 158 | (parameter: EffectParameter.Impl) => 159 | | void 160 | | ((parameter: EffectParameter.Cleanup.Impl) => void) 161 | export namespace Effect { 162 | export type Impl = EffectImpl; 163 | } 164 | 165 | 166 | export type Schema, 167 | ContextSchema = A.Get, 168 | EventsSchema = A.Get 169 | > = 170 | { context?: 171 | A.DoesExtend extends false 172 | ? A.CustomError< 173 | "Error: Use `t` to define type, eg `t<{ foo: number }>()`", 174 | ContextSchema 175 | > : 176 | ContextSchema 177 | , events?: 178 | { [Type in keyof EventsSchema]: 179 | Type extends Definition.ExhaustiveIdentifier 180 | ? boolean : 181 | Type extends Definition.InitialEventType 182 | ? A.CustomError< 183 | `Error: '${Definition.InitialEventType}' is a reserved type`, 184 | A.Get 185 | > : 186 | A.DoesExtend extends false 187 | ? A.CustomError< 188 | "Error: Only string types allowed", 189 | A.Get 190 | > : 191 | A.Get extends infer PayloadWrapped 192 | ? A.DoesExtend extends false 193 | ? A.CustomError< 194 | "Error: Use `t` to define payload type, eg `t<{ foo: number }>()`", 195 | A.Get 196 | > : 197 | A.Get extends infer Payload 198 | ? A.IsPlainObject extends false 199 | ? A.CustomError< 200 | "Error: An event payload should be an object, eg `t<{ foo: number }>()`", 201 | A.Get 202 | > : 203 | "type" extends keyof Payload 204 | ? A.CustomError< 205 | LS.ConcatAll< 206 | [ "Error: An event payload cannot have a property `type` as it's already defined. " 207 | , `In this case as '${S.Assert}'` 208 | ]>, 209 | A.Get 210 | > : 211 | A.Get 212 | : never 213 | : never 214 | } 215 | } 216 | 217 | export type ExhaustiveIdentifier = "$$exhaustive" 218 | export type InitialEventType = "$$initial"; 219 | } 220 | 221 | export type StateValue = 222 | keyof A.Get 223 | 224 | export type InitialStateValue = 225 | A.Get 226 | 227 | type StateValueImpl = string & A.Tag<"Machine.StateValue"> 228 | export namespace StateValue { 229 | export type Impl = StateValueImpl; 230 | } 231 | 232 | export type Context = 233 | A.Get> 234 | 235 | type ContextImpl = {} & A.Tag<"Machine.Context"> 236 | export namespace Context { 237 | export type Impl = ContextImpl; 238 | } 239 | 240 | export type Event> = 241 | | O.Value<{ [T in U.Exclude]: 242 | A.Get extends infer E 243 | ? E extends any ? O.ShallowClean<{ type: T } & E> : never 244 | : never 245 | }> 246 | | ( A.Get extends true ? never : 247 | ( ( O.Value< 248 | { [S in keyof A.Get]: 249 | keyof A.Get 250 | }> extends infer EventType 251 | ? EventType extends any ? { type: EventType } : never 252 | : never 253 | ) 254 | | ( keyof A.Get extends infer EventType 255 | ? EventType extends any ? { type: EventType } : never 256 | : never 257 | ) 258 | ) extends infer InferredEvent 259 | ? InferredEvent extends any 260 | ? A.Get extends keyof EventsSchema ? never : 261 | A.Get extends Definition.ExhaustiveIdentifier ? never : 262 | A.Get extends Definition.InitialEventType ? never : 263 | InferredEvent 264 | : never 265 | : never 266 | ) 267 | 268 | type EventImpl = { type: string & A.Tag<"Machine.Event['type']"> } 269 | export namespace Event { 270 | export type Impl = EventImpl 271 | } 272 | 273 | export namespace EffectParameter { 274 | export interface EffectParameterForStateValue 275 | extends Base 276 | { event: Machine.EntryEventForStateValue 277 | } 278 | 279 | export namespace Cleanup { 280 | export interface ForStateValue 281 | extends Base 282 | { event: Machine.ExitEventForStateValue 283 | } 284 | 285 | export type Impl = EffectParameter.Impl 286 | } 287 | 288 | export interface Base 289 | { send: Machine.Send 290 | , context: Machine.Context 291 | , setContext: Machine.SetContext 292 | } 293 | 294 | export type Impl = EffectParameterImpl; 295 | } 296 | export interface EffectParameterImpl 297 | { event: Event.Impl 298 | , send: Send.Impl 299 | , context: Context.Impl 300 | , setContext: SetContext.Impl 301 | } 302 | 303 | export interface EffectParameterForStateValue 304 | extends BaseEffectParameter 305 | { event: Machine.EntryEventForStateValue 306 | } 307 | 308 | export interface EffectCleanupParameterForStateValue 309 | extends BaseEffectParameter 310 | { event: Machine.ExitEventForStateValue 311 | } 312 | 313 | export interface BaseEffectParameter 314 | { send: Machine.Send 315 | , context: Machine.Context 316 | , setContext: Machine.SetContext 317 | } 318 | 319 | export type EntryEventForStateValue = 320 | | ( StateValue extends InitialStateValue 321 | ? { type: Definition.InitialEventType } 322 | : never 323 | ) 324 | | U.Extract< 325 | Event, 326 | { type: 327 | | O.Value<{ [S in keyof A.Get]: 328 | O.Value<{ [E in keyof A.Get]: 329 | A.Get extends infer T 330 | ? (T extends A.String ? T : A.Get) extends StateValue 331 | ? E 332 | : never 333 | : never 334 | }> 335 | }> 336 | | O.Value<{ [E in keyof A.Get]: 337 | A.Get extends infer T 338 | ? (T extends A.String ? T : A.Get) extends StateValue 339 | ? E 340 | : never 341 | : never 342 | }> 343 | } 344 | > 345 | 346 | export type ExitEventForStateValue = 347 | U.Extract< 348 | Event, 349 | { type: 350 | | keyof A.Get 351 | | keyof A.Get 352 | } 353 | > 354 | 355 | export type Sendable> = 356 | | ( E extends any 357 | ? { type: A.Get } extends E 358 | ? A.Get 359 | : never 360 | : never 361 | ) 362 | | E 363 | type SendableImpl = 364 | | Event.Impl["type"] 365 | | Event.Impl 366 | export namespace Sendable { 367 | export type Impl = SendableImpl; 368 | } 369 | 370 | export type Send = 371 | { (sendable: U.Exclude, A.String>): void 372 | , (sendable: U.Extract, A.String>): void 373 | } 374 | 375 | type SendImpl = (send: Sendable.Impl) => void 376 | export namespace Send { 377 | export type Impl = SendImpl; 378 | } 379 | 380 | export type SetContext = 381 | (contextUpdater: ContextUpdater) => { send: Send } 382 | 383 | export type SetContextImpl = 384 | (context: ContextUpdater.Impl) => { send: Send.Impl } 385 | export namespace SetContext { 386 | export type Impl = SetContextImpl; 387 | } 388 | 389 | export type ContextUpdater = (context: Context) => Context 390 | 391 | type ContextUpdaterImpl = (context: Context.Impl) => Context.Impl 392 | export namespace ContextUpdater { 393 | export type Impl = ContextUpdaterImpl; 394 | } 395 | 396 | export type State, 398 | NextEvents = 399 | ( Value extends any 400 | ? A.Get, "type"> 401 | : never 402 | )[] 403 | > = 404 | Value extends any 405 | ? { value: Value 406 | , context: Context 407 | , event: EntryEventForStateValue 408 | , nextEventsT: A.Get, "type">[] 409 | , nextEvents: NextEvents 410 | } 411 | : never 412 | 413 | interface StateImpl 414 | { value: StateValue.Impl 415 | , context: Context.Impl 416 | , event: Event.Impl 417 | , nextEvents: Event.Impl["type"][] 418 | , nextEventsT: Event.Impl["type"][] 419 | } 420 | export namespace State { 421 | export type Impl = StateImpl 422 | } 423 | } 424 | 425 | export namespace L { 426 | export type Assert = A.Cast; 427 | export type Concat = [...L.Assert, ...L.Assert] 428 | export type Popped = A extends [] ? [] : A extends [...infer X, any] ? X : never; 429 | export type Pop = A extends [] ? undefined : A extends [...any[], infer X] ? X : never; 430 | } 431 | export namespace LS { 432 | export type ConcatAll = 433 | L extends [] ? [] : 434 | L extends [infer H] ? H : 435 | L extends [infer H, ...infer T] ? `${S.Assert}${S.Assert>}` : 436 | never 437 | } 438 | 439 | export namespace S { 440 | export type Assert = A.Cast; 441 | export type IsLiteral = 442 | T extends A.String 443 | ? A.String extends T 444 | ? false 445 | : true 446 | : false; 447 | } 448 | 449 | export namespace F { 450 | export type Call = F extends (...args: any[]) => infer R ? R : never; 451 | export type Parameters = F extends (...args: infer A) => any ? A : never; 452 | } 453 | 454 | export namespace U { 455 | export type Extract = T extends U ? T : never; 456 | export type Exclude = T extends U ? never : T; 457 | } 458 | 459 | export namespace O { 460 | export type Value = T[keyof T]; 461 | export type ShallowClean = { [K in keyof T]: T[K] } 462 | } 463 | 464 | export namespace A { 465 | export type Cast = T extends U ? T : U; 466 | export type Fallback = T extends U ? T : U; 467 | export type Tuple = T[] | [T]; 468 | export type Object = object; 469 | export type String = string; 470 | export type Function = (...args: any[]) => any; 471 | 472 | export type InferNarrowest = 473 | T extends any 474 | ? ( T extends A.Function ? T : 475 | T extends A.Object ? InferNarrowestObject : 476 | T 477 | ) 478 | : never 479 | 480 | export type InferNarrowestObject = 481 | { readonly [K in keyof T]: InferNarrowest } 482 | 483 | export type AreEqual = 484 | (() => T extends B ? 1 : 0) extends (() => T extends A ? 1 : 0) 485 | ? true 486 | : false; 487 | 488 | export type DoesExtend = 489 | A extends B ? true : false; 490 | 491 | export type IsUnknown = 492 | [T] extends [never] 493 | ? false 494 | : T extends unknown ? unknown extends T 495 | ? true 496 | : false : false; 497 | 498 | export type IsPlainObject = 499 | T extends A.Object 500 | ? T extends A.Function ? false : 501 | T extends A.Tuple ? false : 502 | true 503 | : false 504 | 505 | type _Get = 506 | P extends [] ? 507 | T extends undefined ? F : T : 508 | P extends [infer K1, ...infer Kr] ? 509 | K1 extends keyof T ? 510 | _Get : 511 | K1 extends Get.Returned$$ ? 512 | _Get infer R ? R : undefined, Kr, F> : 513 | K1 extends Get.Parameters$$ ? 514 | _Get any ? A : undefined, Kr, F> : 515 | F : 516 | never 517 | 518 | export type Get = 519 | (P extends any[] ? _Get : _Get) extends infer X 520 | ? A.Cast 521 | : never 522 | 523 | export namespace Get { 524 | const Returned$$ = Symbol("Returned$$"); 525 | export type Returned$$ = typeof Returned$$; 526 | 527 | const Parameters$$ = Symbol("Parameters$$"); 528 | export type Parameters$$ = typeof Parameters$$; 529 | } 530 | 531 | export type CustomError = 532 | Place extends (S.IsLiteral extends true ? Error : A.String) 533 | ? Place extends `${S.Assert} ` 534 | ? Error 535 | : `${S.Assert} ` 536 | : Error 537 | 538 | export type Tag = 539 | { [_ in N]: void } 540 | 541 | export const test = (_o: true) => {}; 542 | export const areEqual = (_debug?: (value: A) => void) => undefined as any as A.AreEqual 543 | } 544 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react-hooks"; 2 | import _useStateMachine, { t, Console } from "../src"; 3 | 4 | let log = ""; 5 | const logger: Console["log"] = (...xs) => 6 | log += xs.reduce( 7 | (a, x) => a + (typeof x === "string" ? x : JSON.stringify(x)), 8 | "" 9 | ) 10 | const clearLog = () => 11 | log = ""; 12 | 13 | const useStateMachine = 14 | ((d: any) => 15 | _useStateMachine({ ...d, console: { log: logger } }) 16 | ) as typeof _useStateMachine 17 | 18 | describe("useStateMachine", () => { 19 | describe("States & Transitions", () => { 20 | it("should set initial state", () => { 21 | const { result } = renderHook(() => 22 | useStateMachine({ 23 | initial: "inactive", 24 | states: { 25 | inactive: { 26 | on: { ACTIVATE: "active" }, 27 | }, 28 | active: { 29 | on: { DEACTIVATE: "inactive" }, 30 | }, 31 | }, 32 | }) 33 | ); 34 | 35 | expect(result.current[0]).toStrictEqual({ 36 | context: undefined, 37 | event: { type: "$$initial" }, 38 | value: "inactive", 39 | nextEvents: ["ACTIVATE"], 40 | nextEventsT: ["ACTIVATE"], 41 | }); 42 | }); 43 | 44 | it("should transition", () => { 45 | const { result } = renderHook(() => 46 | useStateMachine({ 47 | initial: "inactive", 48 | states: { 49 | inactive: { 50 | on: { ACTIVATE: "active" }, 51 | }, 52 | active: { 53 | on: { DEACTIVATE: "inactive" }, 54 | }, 55 | }, 56 | }) 57 | ); 58 | 59 | act(() => { 60 | result.current[1]("ACTIVATE"); 61 | }); 62 | 63 | expect(result.current[0]).toStrictEqual({ 64 | context: undefined, 65 | event: { 66 | type: "ACTIVATE", 67 | }, 68 | value: "active", 69 | nextEvents: ["DEACTIVATE"], 70 | nextEventsT: ["DEACTIVATE"], 71 | }); 72 | }); 73 | 74 | it("should transition using a top-level `on`", () => { 75 | const { result } = renderHook(() => 76 | useStateMachine({ 77 | initial: "inactive", 78 | states: { 79 | inactive: { 80 | on: { ACTIVATE: "active" }, 81 | }, 82 | active: { 83 | on: { DEACTIVATE: "inactive" }, 84 | }, 85 | }, 86 | on: { 87 | FORCE_ACTIVATE: "active", 88 | }, 89 | }) 90 | ); 91 | 92 | act(() => { 93 | result.current[1]("FORCE_ACTIVATE"); 94 | }); 95 | 96 | expect(result.current[0]).toStrictEqual({ 97 | context: undefined, 98 | event: { 99 | type: "FORCE_ACTIVATE", 100 | }, 101 | value: "active", 102 | nextEvents: ["DEACTIVATE", "FORCE_ACTIVATE"], 103 | nextEventsT: ["DEACTIVATE", "FORCE_ACTIVATE"], 104 | }); 105 | }); 106 | 107 | it("should transition using an object event", () => { 108 | const { result } = renderHook(() => 109 | useStateMachine({ 110 | initial: "inactive", 111 | states: { 112 | inactive: { 113 | on: { ACTIVATE: "active" }, 114 | }, 115 | active: { 116 | on: { DEACTIVATE: "inactive" }, 117 | }, 118 | }, 119 | }) 120 | ); 121 | 122 | act(() => { 123 | result.current[1]({ type: "ACTIVATE" }); 124 | }); 125 | 126 | expect(result.current[0]).toStrictEqual({ 127 | context: undefined, 128 | event: { 129 | type: "ACTIVATE", 130 | }, 131 | value: "active", 132 | nextEvents: ["DEACTIVATE"], 133 | nextEventsT: ["DEACTIVATE"], 134 | }); 135 | }); 136 | 137 | it("should ignore unexisting events", () => { 138 | const { result } = renderHook(() => 139 | useStateMachine({ 140 | initial: "inactive", 141 | states: { 142 | inactive: { 143 | on: { TOGGLE: "active" }, 144 | }, 145 | active: { 146 | on: { TOGGLE: "inactive" }, 147 | }, 148 | }, 149 | }) 150 | ); 151 | 152 | act(() => { 153 | // TypeScript won"t allow me to type "ON" because it knows it"s not a valid event 154 | // @ts-expect-error 155 | result.current[1]("ON"); 156 | }); 157 | 158 | expect(result.current[0]).toStrictEqual({ 159 | context: undefined, 160 | event: { type: "$$initial" }, 161 | value: "inactive", 162 | nextEvents: ["TOGGLE"], 163 | nextEventsT: ["TOGGLE"], 164 | }); 165 | }); 166 | 167 | it("should transition with object syntax", () => { 168 | const { result } = renderHook(() => 169 | useStateMachine({ 170 | initial: "inactive", 171 | states: { 172 | inactive: { 173 | on: { 174 | TOGGLE: { 175 | target: "active", 176 | }, 177 | }, 178 | }, 179 | active: { 180 | on: { 181 | TOGGLE: { 182 | target: "inactive", 183 | }, 184 | }, 185 | }, 186 | }, 187 | }) 188 | ); 189 | 190 | act(() => { 191 | result.current[1]("TOGGLE"); 192 | }); 193 | 194 | expect(result.current[0]).toStrictEqual({ 195 | context: undefined, 196 | event: { 197 | type: "TOGGLE", 198 | }, 199 | value: "active", 200 | nextEvents: ["TOGGLE"], 201 | nextEventsT: ["TOGGLE"], 202 | }); 203 | }); 204 | it("should invoke effect callbacks", () => { 205 | const entry = jest.fn(); 206 | const exit = jest.fn(); 207 | const { result } = renderHook(() => 208 | useStateMachine({ 209 | initial: "inactive", 210 | states: { 211 | inactive: { 212 | on: { TOGGLE: "active" }, 213 | effect() { 214 | entry("inactive"); 215 | return exit.bind(null, "inactive"); 216 | }, 217 | }, 218 | active: { 219 | on: { TOGGLE: "inactive" }, 220 | effect() { 221 | entry("active"); 222 | return exit.bind(null, "active"); 223 | }, 224 | }, 225 | }, 226 | }) 227 | ); 228 | 229 | act(() => { 230 | result.current[1]("TOGGLE"); 231 | }); 232 | 233 | expect(entry.mock.calls.length).toBe(2); 234 | expect(exit.mock.calls.length).toBe(1); 235 | 236 | expect(entry.mock.invocationCallOrder).toEqual([1, 3]); 237 | expect(exit.mock.invocationCallOrder).toEqual([2]); 238 | 239 | expect(entry.mock.calls[0][0]).toBe("inactive"); 240 | expect(entry.mock.calls[1][0]).toBe("active"); 241 | 242 | expect(exit.mock.calls[0][0]).toBe("inactive"); 243 | }); 244 | 245 | it("should transition from effect", () => { 246 | const { result } = renderHook(() => 247 | useStateMachine({ 248 | initial: "inactive", 249 | states: { 250 | inactive: { 251 | on: { TOGGLE: "active" }, 252 | effect({ send }) { 253 | send("TOGGLE"); 254 | }, 255 | }, 256 | active: { 257 | on: { TOGGLE: "inactive" }, 258 | }, 259 | }, 260 | }) 261 | ); 262 | 263 | expect(result.current[0]).toStrictEqual({ 264 | context: undefined, 265 | event: { 266 | type: "TOGGLE", 267 | }, 268 | value: "active", 269 | nextEvents: ["TOGGLE"], 270 | nextEventsT: ["TOGGLE"], 271 | }); 272 | }); 273 | 274 | it("should get payload sent with event object", () => { 275 | const effect = jest.fn(); 276 | const { result } = renderHook(() => 277 | useStateMachine({ 278 | schema: { 279 | events: { 280 | ACTIVATE: t<{ number: number }>() 281 | } 282 | }, 283 | context: undefined, 284 | initial: "inactive", 285 | states: { 286 | inactive: { 287 | on: { ACTIVATE: "active" }, 288 | }, 289 | active: { 290 | on: { DEACTIVATE: "inactive" }, 291 | effect, 292 | }, 293 | }, 294 | }) 295 | ); 296 | 297 | act(() => { 298 | result.current[1]({ type: "ACTIVATE", number: 10 }); 299 | }); 300 | expect(effect.mock.calls[0][0]["event"]).toStrictEqual({ type: "ACTIVATE", number: 10 }); 301 | }); 302 | it("should invoke effect with context as a parameter", () => { 303 | const finalEffect = jest.fn(); 304 | const initialEffect = jest.fn(({ setContext }) => { 305 | setContext((context: boolean) => !context).send("TOGGLE"); 306 | }); 307 | 308 | renderHook(() => 309 | useStateMachine({ 310 | context: false, 311 | initial: "inactive", 312 | states: { 313 | inactive: { 314 | on: { TOGGLE: "active" }, 315 | effect: initialEffect, 316 | }, 317 | active: { 318 | effect: finalEffect, 319 | }, 320 | }, 321 | }) 322 | ); 323 | 324 | expect(initialEffect).toHaveBeenCalledTimes(1); 325 | expect(initialEffect.mock.calls[0][0]["context"]).toBe(false); 326 | 327 | expect(finalEffect).toHaveBeenCalledTimes(1); 328 | expect(finalEffect.mock.calls[0][0]["context"]).toBe(true); 329 | }); 330 | }); 331 | describe("guarded transitions", () => { 332 | it("should block transitions with guard returning false", () => { 333 | const guard = jest.fn(() => false); 334 | 335 | const { result } = renderHook(() => 336 | useStateMachine({ 337 | initial: "inactive", 338 | states: { 339 | inactive: { 340 | on: { 341 | TOGGLE: { 342 | target: "active", 343 | guard, 344 | }, 345 | }, 346 | }, 347 | active: { 348 | on: { TOGGLE: "inactive" }, 349 | }, 350 | }, 351 | }) 352 | ); 353 | 354 | act(() => { 355 | result.current[1]("TOGGLE"); 356 | }); 357 | 358 | expect(guard).toHaveBeenCalled(); 359 | expect(result.current[0]).toStrictEqual({ 360 | context: undefined, 361 | event: { type: "$$initial" }, 362 | value: "inactive", 363 | nextEvents: ["TOGGLE"], 364 | nextEventsT: ["TOGGLE"], 365 | }); 366 | }); 367 | 368 | it("should allow transitions with guard returning true", () => { 369 | const guard = jest.fn(() => true); 370 | 371 | const { result } = renderHook(() => 372 | useStateMachine({ 373 | initial: "inactive", 374 | states: { 375 | inactive: { 376 | on: { 377 | TOGGLE: { 378 | target: "active", 379 | guard, 380 | }, 381 | }, 382 | }, 383 | active: { 384 | on: { TOGGLE: "inactive" }, 385 | }, 386 | }, 387 | }) 388 | ); 389 | 390 | act(() => { 391 | result.current[1]("TOGGLE"); 392 | }); 393 | 394 | expect(guard).toHaveBeenCalled(); 395 | expect(result.current[0]).toStrictEqual({ 396 | context: undefined, 397 | event: { 398 | type: "TOGGLE", 399 | }, 400 | value: "active", 401 | nextEvents: ["TOGGLE"], 402 | nextEventsT: ["TOGGLE"], 403 | }); 404 | }); 405 | }); 406 | describe("Extended State", () => { 407 | it("should set initial context", () => { 408 | const { result } = renderHook(() => 409 | useStateMachine({ 410 | context: { foo: "bar" }, 411 | initial: "inactive", 412 | states: { 413 | inactive: { 414 | on: { TOGGLE: "active" }, 415 | }, 416 | active: { 417 | on: { TOGGLE: "inactive" }, 418 | }, 419 | }, 420 | }) 421 | ); 422 | 423 | expect(result.current[0]).toStrictEqual({ 424 | value: "inactive", 425 | context: { foo: "bar" }, 426 | event: { type: "$$initial" }, 427 | nextEvents: ["TOGGLE"], 428 | nextEventsT: ["TOGGLE"], 429 | }); 430 | }); 431 | 432 | it("should get the context inside effects", () => { 433 | const { result } = renderHook(() => 434 | useStateMachine({ 435 | context: { foo: "bar" }, 436 | initial: "inactive", 437 | states: { 438 | inactive: { 439 | on: { TOGGLE: "active" }, 440 | effect(params) { 441 | expect(params.context).toStrictEqual({ 442 | foo: "bar", 443 | }); 444 | expect(params.event).toStrictEqual({ 445 | type: "$$initial" 446 | }) 447 | }, 448 | }, 449 | active: { 450 | on: { TOGGLE: "inactive" }, 451 | }, 452 | }, 453 | }) 454 | ); 455 | 456 | expect(result.current[0]).toStrictEqual({ 457 | value: "inactive", 458 | context: { foo: "bar" }, 459 | event: { type: "$$initial" }, 460 | nextEvents: ["TOGGLE"], 461 | nextEventsT: ["TOGGLE"], 462 | }); 463 | }); 464 | 465 | it("should update context on entry", () => { 466 | const { result } = renderHook(() => 467 | useStateMachine({ 468 | context: { toggleCount: 0 }, 469 | initial: "inactive", 470 | states: { 471 | inactive: { 472 | on: { TOGGLE: "active" }, 473 | }, 474 | active: { 475 | on: { TOGGLE: "inactive" }, 476 | effect({ setContext }) { 477 | setContext(c => ({ toggleCount: c.toggleCount + 1 })); 478 | }, 479 | }, 480 | }, 481 | }) 482 | ); 483 | 484 | act(() => { 485 | result.current[1]("TOGGLE"); 486 | }); 487 | 488 | expect(result.current[0]).toStrictEqual({ 489 | value: "active", 490 | context: { toggleCount: 1 }, 491 | event: { 492 | type: "TOGGLE", 493 | }, 494 | nextEvents: ["TOGGLE"], 495 | nextEventsT: ["TOGGLE"], 496 | }); 497 | }); 498 | it("should update context on exit", () => { 499 | const { result } = renderHook(() => 500 | useStateMachine({ 501 | context: { toggleCount: 0 }, 502 | initial: "inactive", 503 | states: { 504 | inactive: { 505 | on: { TOGGLE: "active" }, 506 | effect({ setContext }) { 507 | return () => setContext(c => ({ toggleCount: c.toggleCount + 1 })); 508 | }, 509 | }, 510 | active: { 511 | on: { TOGGLE: "inactive" }, 512 | }, 513 | }, 514 | }) 515 | ); 516 | 517 | act(() => { 518 | result.current[1]("TOGGLE"); 519 | }); 520 | 521 | expect(result.current[0]).toStrictEqual({ 522 | value: "active", 523 | context: { toggleCount: 1 }, 524 | event: { 525 | type: "TOGGLE", 526 | }, 527 | nextEvents: ["TOGGLE"], 528 | nextEventsT: ["TOGGLE"], 529 | }); 530 | }); 531 | }); 532 | describe("Verbose Mode (Logger)", () => { 533 | it("should log when invalid event is provided as string", () => { 534 | clearLog(); 535 | renderHook(() => 536 | useStateMachine({ 537 | verbose: true, 538 | initial: "idle", 539 | states: { 540 | idle: { 541 | on: null, 542 | effect: ({ send }) => 543 | // @ts-expect-error 544 | send("invalid"), 545 | }, 546 | }, 547 | }) 548 | ); 549 | 550 | expect(log).toMatch(/invalid/); 551 | }); 552 | 553 | it("should log when invalid event is provided as object", () => { 554 | clearLog(); 555 | renderHook(() => 556 | useStateMachine({ 557 | verbose: true, 558 | initial: "idle", 559 | states: { 560 | idle: { 561 | on: null, 562 | effect: ({ send }) => 563 | // @ts-expect-error 564 | send({ type: "invalid" }), 565 | }, 566 | }, 567 | }) 568 | ); 569 | 570 | expect(log).toMatch(/invalid/); 571 | }); 572 | }); 573 | describe("React performance", () => { 574 | it("should provide a stable `send`", () => { 575 | const { result, rerender } = renderHook(() => 576 | useStateMachine({ 577 | initial: "inactive", 578 | states: { 579 | inactive: { 580 | on: { TOGGLE: "active" }, 581 | }, 582 | active: { 583 | on: { TOGGLE: "inactive" }, 584 | }, 585 | }, 586 | }) 587 | ); 588 | 589 | act(() => { 590 | rerender(); 591 | }); 592 | 593 | if (result.all[0] instanceof Error) throw result.all[0]; 594 | else if (result.all[1] instanceof Error) throw result.all[1]; 595 | else expect(result.all[0][1]).toBe(result.all[1][1]); 596 | }); 597 | }); 598 | }); 599 | -------------------------------------------------------------------------------- /test/types.twoslash-test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { A, LS, UseStateMachine, CreateType } from "../src/types"; 3 | 4 | const useStateMachine = (() => []) as any as UseStateMachine; 5 | const t = (() => {}) as CreateType 6 | 7 | const query = () => 8 | ((global as any).twoSlashQueries.shift()) as { completions: string[], text: string } 9 | 10 | describe("Machine.Definition", () => { 11 | 12 | describe("Machine.Definition['initial']", () => { 13 | it("expects one of the child state identifiers", () => { 14 | useStateMachine({ 15 | initial: "a", 16 | states: { 17 | a: {}, 18 | b: {} 19 | } 20 | }) 21 | 22 | useStateMachine({ 23 | // @ts-expect-error 24 | initial: "", 25 | states: { 26 | a: {}, 27 | b: {} 28 | } 29 | }) 30 | }) 31 | 32 | it("shows child state identifiers as completions", () => { 33 | useStateMachine({ 34 | // @ts-expect-error 35 | initial: " ", 36 | // ^| 37 | states: { 38 | a: {}, 39 | b: {} 40 | } 41 | }) 42 | 43 | expect(query().completions).toStrictEqual(["a", "b"]) 44 | }) 45 | 46 | it("shows custom error in case of no states", () => { 47 | useStateMachine({ 48 | // @ts-expect-error 49 | initial: "" 50 | // ^? 51 | }) 52 | expect(query().text).toContain(`"Error: no states defined"`) 53 | 54 | useStateMachine({ 55 | // @ts-expect-error 56 | initial: "Error: no states defined" 57 | }) 58 | }) 59 | }) 60 | 61 | describe("Machine.Definition['states']", () => { 62 | it("expects only strings as key", () => { 63 | useStateMachine({ 64 | initial: "a", 65 | states: { 66 | a: {} 67 | } 68 | }) 69 | 70 | useStateMachine({ 71 | initial: 1, 72 | states: { 73 | // @ts-expect-error 74 | 1: {} 75 | } 76 | }) 77 | }) 78 | 79 | it("shows custom error in case of identifiers other than string", () => { 80 | useStateMachine({ 81 | initial: 1, 82 | states: { 83 | // @ts-expect-error 84 | 1: {} 85 | // ^? 86 | } 87 | }) 88 | expect(query().text).toContain(`"Error: Only string identifiers allowed"`) 89 | 90 | useStateMachine({ 91 | initial: 1, 92 | states: { 93 | // @ts-expect-error 94 | 1: "Error: Only string identifiers allowed" 95 | } 96 | }) 97 | }) 98 | }) 99 | 100 | describe("Machine.Definition['schema']", () => { 101 | it("is optional", () => { 102 | useStateMachine({ 103 | initial: "a", 104 | states: { a: {} } 105 | }) 106 | }) 107 | 108 | describe("MachineDefinition['schema']['events']", () => { 109 | it("is optional", () => { 110 | useStateMachine({ 111 | schema: {}, 112 | initial: "a", 113 | states: { a: {} } 114 | }) 115 | }) 116 | 117 | it("expects event payload type be created from t", () => { 118 | useStateMachine({ 119 | schema: { 120 | events: { 121 | // @ts-expect-error 122 | X: {} 123 | } 124 | }, 125 | initial: "a", 126 | states: { a: {} } 127 | }) 128 | }) 129 | 130 | it("shows custom error when event payload type is not created from t", () => { 131 | useStateMachine({ 132 | schema: { 133 | events: { 134 | // @ts-expect-error 135 | X: {} 136 | // ^? 137 | } 138 | }, 139 | initial: "a", 140 | states: { a: {} } 141 | }) 142 | 143 | expect(query().text).toContain("Error: Use `t` to define payload type, eg `t<{ foo: number }>()`") 144 | 145 | useStateMachine({ 146 | schema: { 147 | events: { 148 | // @ts-expect-error 149 | X: "Error: Use `t` to define payload type, eg `t<{ foo: number }>()`" 150 | } 151 | }, 152 | initial: "a", 153 | states: { a: {} } 154 | }) 155 | }) 156 | 157 | it("expects event payload to extend an object", () => { 158 | useStateMachine({ 159 | schema: { 160 | events: { 161 | X: t<{ foo: number }>() 162 | } 163 | }, 164 | initial: "a", 165 | states: { a: {} } 166 | }) 167 | 168 | useStateMachine({ 169 | schema: { 170 | events: { 171 | // @ts-expect-error 172 | X: t<1>() 173 | } 174 | } 175 | }) 176 | 177 | useStateMachine({ 178 | schema: { 179 | events: { 180 | // @ts-expect-error 181 | X: t<"FOO">() 182 | } 183 | } 184 | }) 185 | }) 186 | 187 | it("shows custom error in case of event payload not extending an object", () => { 188 | useStateMachine({ 189 | schema: { 190 | events: { 191 | // @ts-expect-error 192 | X: t<"FOO">() 193 | // ^? 194 | } 195 | } 196 | }) 197 | expect(query().text).toContain("Error: An event payload should be an object, eg `t<{ foo: number }>()`") 198 | 199 | useStateMachine({ 200 | schema: { 201 | events: { 202 | // @ts-expect-error 203 | X: t<"Error: An event payload should be an object, eg `t<{ foo: number }>()`">() 204 | } 205 | } 206 | }) 207 | }) 208 | 209 | it("expects event payload to not have `type` property", () => { 210 | useStateMachine({ 211 | schema: { 212 | events: { 213 | // @ts-expect-error 214 | X: t<{ type: number }>() 215 | } 216 | }, 217 | initial: "a", 218 | states: { a: {} } 219 | }) 220 | }) 221 | 222 | it("shows custom error when event payload has a `type` property", () => { 223 | useStateMachine({ 224 | schema: { 225 | events: { 226 | // @ts-expect-error 227 | X: t<{ type: number, foo: string }>() 228 | // ^? 229 | } 230 | }, 231 | initial: "a", 232 | states: { a: {} } 233 | }) 234 | 235 | expect(query().text).toContain( 236 | "Error: An event payload cannot have a property `type` as it's already defined. In this case as 'X'" 237 | ) 238 | 239 | useStateMachine({ 240 | schema: { 241 | events: { 242 | // @ts-expect-error 243 | X: t< 244 | "Error: An event payload cannot have a property `type` as it's already defined. In this case as 'X'" 245 | >() 246 | } 247 | }, 248 | initial: "a", 249 | states: { a: {} } 250 | }) 251 | }) 252 | 253 | it("expects $$exhaustive to be a boolean", () => { 254 | useStateMachine({ 255 | schema: { 256 | events: { 257 | $$exhaustive: true 258 | } 259 | }, 260 | initial: "a", 261 | states: { a: {} } 262 | }) 263 | 264 | useStateMachine({ 265 | schema: { 266 | events: { 267 | // @ts-expect-error 268 | $$exhaustive: 1 269 | } 270 | }, 271 | initial: "a", 272 | states: { a: {} } 273 | }) 274 | }) 275 | 276 | it("expects $$initial to not be a type", () => { 277 | useStateMachine({ 278 | schema: { 279 | events: { 280 | // @ts-expect-error 281 | $$initial: t<{}>() 282 | } 283 | }, 284 | initial: "a", 285 | states: { a: {} } 286 | }) 287 | }) 288 | 289 | it("shows custom error in case of $$initial as a type", () => { 290 | useStateMachine({ 291 | schema: { 292 | events: { 293 | // @ts-expect-error 294 | $$initial: t<{}>() 295 | // ^? 296 | } 297 | }, 298 | initial: "a", 299 | states: { a: {} } 300 | }) 301 | 302 | expect(query().text).toContain("Error: '$$initial' is a reserved type") 303 | 304 | useStateMachine({ 305 | schema: { 306 | events: { 307 | // @ts-expect-error 308 | $$initial: "Error: '$$initial' is a reserved type" 309 | } 310 | }, 311 | initial: "a", 312 | states: { a: {} } 313 | }) 314 | }) 315 | }) 316 | 317 | describe("MachineDefinition['schema']['context']", () => { 318 | it("is optional", () => { 319 | useStateMachine({ 320 | schema: {}, 321 | initial: "a", 322 | states: { a: {} } 323 | }) 324 | }) 325 | 326 | it("expects type be created from t", () => { 327 | useStateMachine({ 328 | schema: { 329 | // @ts-expect-error 330 | context: {} 331 | }, 332 | initial: "a", 333 | states: { a: {} } 334 | }) 335 | }) 336 | 337 | it("shows custom error when type is not created from t", () => { 338 | useStateMachine({ 339 | schema: { 340 | // @ts-expect-error 341 | context: {} 342 | // ^? 343 | }, 344 | initial: "a", 345 | states: { a: {} } 346 | }) 347 | 348 | expect(query().text).toContain("Error: Use `t` to define type, eg `t<{ foo: number }>()`") 349 | 350 | useStateMachine({ 351 | schema: { 352 | // @ts-expect-error 353 | context: "Error: Use `t` to define type, eg `t<{ foo: number }>()`" 354 | }, 355 | initial: "a", 356 | states: { a: {} } 357 | }) 358 | }) 359 | 360 | it("expects any type", () => { 361 | useStateMachine({ 362 | schema: { context: t<{ foo?: number }>() }, 363 | context: { foo: 1 }, 364 | initial: "a", 365 | states: { a: {} } 366 | }) 367 | 368 | useStateMachine({ 369 | schema: { context: t<"foo">() }, 370 | context: "foo", 371 | initial: "a", 372 | states: { a: {} } 373 | }) 374 | }) 375 | }) 376 | }) 377 | 378 | describe("Machine.Definition['context']", () => { 379 | it("honours schema.context", () => { 380 | // @ts-expect-error 381 | useStateMachine({ 382 | schema: { 383 | context: t<{ foo: number }>() 384 | }, 385 | initial: "a", 386 | states: { a: {} } 387 | }) 388 | 389 | useStateMachine({ 390 | schema: { 391 | context: t<{ foo: number }>() 392 | }, 393 | context: { 394 | // @ts-expect-error 395 | foo: "" 396 | }, 397 | initial: "a", 398 | states: { a: {} } 399 | }) 400 | 401 | useStateMachine({ 402 | schema: { 403 | context: t<{ foo: number }>() 404 | }, 405 | context: { foo: 1 }, 406 | initial: "a", 407 | states: { a: {} } 408 | }) 409 | 410 | useStateMachine({ 411 | schema: { 412 | context: t() 413 | }, 414 | // @ts-expect-error 415 | context: { foo: 1 }, 416 | initial: "a", 417 | states: { a: {} } 418 | }) 419 | 420 | useStateMachine({ 421 | schema: {}, 422 | context: { foo: 1 }, 423 | initial: "a", 424 | states: { a: {} } 425 | }) 426 | 427 | useStateMachine({ 428 | context: { foo: 1 }, 429 | initial: "a", 430 | states: { a: {} } 431 | }) 432 | }) 433 | 434 | it("doesn't infer narrowest", () => { 435 | let [state] = useStateMachine({ 436 | schema: {}, 437 | context: { foo: "hello" }, 438 | initial: "a", 439 | states: { a: {} } 440 | }) 441 | A.test(A.areEqual()) 442 | }) 443 | }) 444 | 445 | describe("Machine.Definition['verbose']", () => { 446 | it("expects boolean", () => { 447 | useStateMachine({ 448 | initial: "a", 449 | states: { a: {} }, 450 | verbose: true 451 | }) 452 | 453 | useStateMachine({ 454 | initial: "a", 455 | states: { a: {} }, 456 | // @ts-expect-error 457 | verbose: 1 458 | }) 459 | }) 460 | }) 461 | 462 | describe("Machine.Definition.On", () => { 463 | it("expects only strings as key", () => { 464 | useStateMachine({ 465 | initial: "a", 466 | states: { 467 | a: { 468 | on: { 469 | X: "a" 470 | } 471 | } 472 | }, 473 | on: { 474 | Y: "a" 475 | } 476 | }) 477 | 478 | useStateMachine({ 479 | initial: "a", 480 | states: { 481 | a: { 482 | on: { 483 | // @ts-expect-error 484 | 1: "a" 485 | } 486 | } 487 | }, 488 | on: { 489 | // @ts-expect-error 490 | 2: "a" 491 | } 492 | }) 493 | }) 494 | 495 | it("shows custom error in case of identifiers other than string", () => { 496 | useStateMachine({ 497 | initial: "a", 498 | states: { 499 | a: { 500 | on: { 501 | // @ts-expect-error 502 | 1: "a" 503 | // ^? 504 | } 505 | } 506 | } 507 | }) 508 | expect(query().text).toContain(`"Error: only string types allowed"`) 509 | 510 | useStateMachine({ 511 | initial: "a", 512 | states: { 513 | a: { 514 | on: { 515 | // @ts-expect-error 516 | 1: "Error: only string types allowed" 517 | } 518 | } 519 | } 520 | }) 521 | 522 | useStateMachine({ 523 | initial: "a", 524 | states: { 525 | a: {} 526 | }, 527 | on: { 528 | // @ts-expect-error 529 | 1: "a" 530 | // ^? 531 | } 532 | }) 533 | expect(query().text).toContain(`"Error: only string types allowed"`) 534 | 535 | useStateMachine({ 536 | initial: "a", 537 | states: { 538 | a: {} 539 | }, 540 | on: { 541 | // @ts-expect-error 542 | 1: "Error: only string types allowed" 543 | } 544 | }) 545 | }) 546 | 547 | it("expects $$exhaustive to not be a key", () => { 548 | useStateMachine({ 549 | initial: "a", 550 | states: { 551 | a: { 552 | on: { 553 | //@ts-expect-error 554 | $$exhaustive: "a" 555 | } 556 | } 557 | }, 558 | on: { 559 | //@ts-expect-error 560 | $$exhaustive: "a" 561 | } 562 | }) 563 | }) 564 | 565 | it("shows custom error in case of $$exhaustive as a key", () => { 566 | useStateMachine({ 567 | initial: "a", 568 | states: { 569 | a: { 570 | on: { 571 | // @ts-expect-error 572 | $$exhaustive: "a" 573 | // ^? 574 | } 575 | } 576 | } 577 | }) 578 | 579 | expect(query().text).toContain("Error: '$$exhaustive' is a reserved name") 580 | 581 | useStateMachine({ 582 | initial: "a", 583 | states: { 584 | a: { 585 | on: { 586 | // @ts-expect-error 587 | $$exhaustive: "Error: '$$exhaustive' is a reserved name" 588 | } 589 | } 590 | } 591 | }) 592 | 593 | useStateMachine({ 594 | initial: "a", 595 | states: { 596 | a: {} 597 | }, 598 | on: { 599 | // @ts-expect-error 600 | $$exhaustive: "a" 601 | // ^? 602 | } 603 | }) 604 | 605 | expect(query().text).toContain("Error: '$$exhaustive' is a reserved name") 606 | 607 | useStateMachine({ 608 | initial: "a", 609 | states: { 610 | a: {} 611 | }, 612 | on: { 613 | // @ts-expect-error 614 | $$exhaustive: "Error: '$$exhaustive' is a reserved name" 615 | } 616 | }) 617 | }) 618 | 619 | it("honours schema.event", () => { 620 | useStateMachine({ 621 | schema: { 622 | events: { 623 | $$exhaustive: true, 624 | X: t<{}>(), 625 | Y: t<{}>() 626 | } 627 | }, 628 | initial: "a", 629 | states: { 630 | a: { 631 | on: { 632 | X: "a", 633 | Y: "a", 634 | // @ts-expect-error 635 | Z: "a" 636 | } 637 | } 638 | }, 639 | on: { 640 | X: "a", 641 | Y: "a", 642 | // @ts-expect-error 643 | Z: "a" 644 | } 645 | }) 646 | 647 | useStateMachine({ 648 | schema: { 649 | events: { 650 | $$exhaustive: false, 651 | X: t<{}>(), 652 | Y: t<{}>() 653 | } 654 | }, 655 | initial: "a", 656 | states: { 657 | a: { 658 | on: { 659 | Z: "a" 660 | } 661 | } 662 | }, 663 | on: { 664 | Z: "a" 665 | } 666 | }) 667 | 668 | useStateMachine({ 669 | schema: { 670 | events: { 671 | X: t<{}>(), 672 | Y: t<{}>() 673 | } 674 | }, 675 | initial: "a", 676 | states: { 677 | a: { 678 | on: { 679 | Z: "a" 680 | } 681 | } 682 | }, 683 | on: { 684 | Z: "a" 685 | } 686 | }) 687 | }) 688 | 689 | it("shows custom error in case of violation of schema.events", () => { 690 | useStateMachine({ 691 | schema: { 692 | events: { 693 | $$exhaustive: true, 694 | X: t<{}>(), 695 | Y: t<{}>() 696 | } 697 | }, 698 | initial: "a", 699 | states: { 700 | a: { 701 | on: { 702 | X: "a", 703 | Y: "a", 704 | // @ts-expect-error 705 | Z: "a" 706 | // ^? 707 | } 708 | } 709 | } 710 | }) 711 | expect(query().text).toContain( 712 | "Error: Event type 'Z' is not found in schema.events which is marked as exhaustive" 713 | ) 714 | 715 | useStateMachine({ 716 | schema: { 717 | events: { 718 | $$exhaustive: true, 719 | X: t<{}>(), 720 | Y: t<{}>() 721 | } 722 | }, 723 | initial: "a", 724 | states: { 725 | a: { 726 | on: { 727 | X: "a", 728 | Y: "a", 729 | // @ts-expect-error 730 | Z: "Error: Event type 'Z' is not found in schema.events which is marked as exhaustive" 731 | } 732 | } 733 | } 734 | }) 735 | 736 | useStateMachine({ 737 | schema: { 738 | events: { 739 | $$exhaustive: true, 740 | X: t<{}>(), 741 | Y: t<{}>() 742 | } 743 | }, 744 | initial: "a", 745 | states: { 746 | a: {} 747 | }, 748 | on: { 749 | X: "a", 750 | Y: "a", 751 | // @ts-expect-error 752 | Z: "a" 753 | // ^? 754 | } 755 | }) 756 | expect(query().text).toContain( 757 | "Error: Event type 'Z' is not found in schema.events which is marked as exhaustive" 758 | ) 759 | 760 | useStateMachine({ 761 | schema: { 762 | events: { 763 | $$exhaustive: true, 764 | X: t<{}>(), 765 | Y: t<{}>() 766 | } 767 | }, 768 | initial: "a", 769 | states: { 770 | a: {} 771 | }, 772 | on: { 773 | X: "a", 774 | Y: "a", 775 | // @ts-expect-error 776 | Z: "Error: Event type 'Z' is not found in schema.events which is marked as exhaustive" 777 | } 778 | }) 779 | }) 780 | }) 781 | 782 | describe("Machine.Definition.Effect", () => { 783 | useStateMachine({ 784 | schema: { 785 | events: { 786 | X: t<{ foo: number }>(), 787 | Y: t<{ bar?: number }>(), 788 | Z: t<{ baz: string }>() 789 | }, 790 | context: t<{ foo?: number }>() 791 | }, 792 | context: {}, 793 | initial: "a", 794 | on: { 795 | Z: "b" 796 | }, 797 | states: { 798 | a: { 799 | on: { 800 | X: "b", 801 | } 802 | }, 803 | b: { 804 | on: { 805 | Y: "a" 806 | }, 807 | effect: effectParameter => { 808 | 809 | describe("Machine.EntryEventForStateValue", () => { 810 | effectParameter.event?.type 811 | 812 | A.test(A.areEqual< 813 | typeof effectParameter.event, 814 | | { type: "X", foo: number } 815 | | { type: "Z", baz: string } 816 | >()) 817 | }) 818 | 819 | A.test(A.areEqual< 820 | typeof effectParameter.send, 821 | { ( sendable: 822 | | { type: "X", foo: number } 823 | | { type: "Y", bar?: number } 824 | | { type: "Z", baz: string } 825 | ): void 826 | , ( sendable: 827 | | "Y" 828 | ): void 829 | } 830 | >()) 831 | 832 | A.test(A.areEqual< 833 | typeof effectParameter.context, 834 | { foo?: number } 835 | >()) 836 | 837 | let { send } = effectParameter.setContext(context => { 838 | A.test(A.areEqual()) 839 | return {} 840 | }) 841 | 842 | // @ts-expect-error 843 | effectParameter.setContext(() => ({ foo: "" })) 844 | 845 | A.test(A.areEqual< 846 | typeof send, 847 | { ( sendable: 848 | | { type: "X", foo: number } 849 | | { type: "Y", bar?: number } 850 | | { type: "Z", baz: string } 851 | ): void 852 | , ( sendable: 853 | | "Y" 854 | ): void 855 | } 856 | >()) 857 | 858 | return (cleanupParameter) => { 859 | 860 | describe("Machine.ExitEventForStateValue", () => { 861 | A.test(A.areEqual< 862 | typeof cleanupParameter.event, 863 | | { type: "Y", bar?: number } 864 | | { type: "Z", baz: string } 865 | >()) 866 | }) 867 | 868 | A.test(A.areEqual< 869 | typeof cleanupParameter.send, 870 | { ( sendable: 871 | | { type: "X", foo: number } 872 | | { type: "Y", bar?: number } 873 | | { type: "Z", baz: string } 874 | ): void 875 | , ( sendable: 876 | | "Y" 877 | ): void 878 | } 879 | >()) 880 | 881 | A.test(A.areEqual< 882 | typeof cleanupParameter.context, 883 | { foo?: number } 884 | >()) 885 | 886 | let { send } = cleanupParameter.setContext(context => { 887 | A.test(A.areEqual()) 888 | return {} 889 | }) 890 | 891 | // @ts-expect-error 892 | cleanupParameter.setContext(() => ({ foo: "" })) 893 | 894 | A.test(A.areEqual< 895 | typeof send, 896 | { ( sendable: 897 | | { type: "X", foo: number } 898 | | { type: "Y", bar?: number } 899 | | { type: "Z", baz: string } 900 | ): void 901 | , ( sendable: 902 | | "Y" 903 | ): void 904 | } 905 | >()) 906 | } 907 | } 908 | }, 909 | c: { 910 | on: {}, 911 | // @ts-expect-error 912 | effect: () => { return "foo" } 913 | } 914 | } 915 | }) 916 | }) 917 | 918 | describe("single-functional-property bug", () => { 919 | useStateMachine({ 920 | // @ts-ignore 921 | initial: "a", 922 | states: { 923 | a: { 924 | // @ts-ignore 925 | effect: p => { 926 | } 927 | } 928 | } 929 | }) 930 | 931 | it("workaround works", () => { 932 | useStateMachine({ 933 | initial: "a", 934 | states: { 935 | a: { 936 | on: {}, 937 | effect: parameter => { 938 | A.test(A.areEqual< 939 | keyof typeof parameter, 940 | "event" | "send" | "context" | "setContext" 941 | >()) 942 | } 943 | } 944 | } 945 | }) 946 | }) 947 | 948 | it("shows custom error instructing required change", () => { 949 | useStateMachine({ 950 | // @ts-expect-error 951 | initial: "a", 952 | // ^? 953 | states: { 954 | a: { 955 | // @ts-ignore 956 | effect: p => { 957 | } 958 | } 959 | } 960 | }) 961 | 962 | expect(query().text).toContain( 963 | "Oops you have met a TypeScript limitation, " + 964 | "please add `on: {}` to state nodes that only have an `effect` property. " + 965 | "See the documentation to learn more." 966 | ) 967 | 968 | // @ts-expect-error 969 | useStateMachine({} as LS.ConcatAll< 970 | [ "Oops you have met a TypeScript limitation, " 971 | , "please add `on: {}` to state nodes that only have an `effect` property. " 972 | , "See the documentation to learn more." 973 | ]>) 974 | }) 975 | }) 976 | 977 | describe("Machine.Definition.Transition", () => { 978 | it("expects target string", () => { 979 | useStateMachine({ 980 | initial: "a", 981 | states: { 982 | a: { 983 | on: { 984 | X: "b" 985 | } 986 | }, 987 | b: {}, 988 | c: {} 989 | } 990 | }) 991 | 992 | useStateMachine({ 993 | initial: "a", 994 | states: { 995 | a: { 996 | on: { 997 | // @ts-expect-error 998 | X: "" 999 | } 1000 | }, 1001 | b: {}, 1002 | c: {} 1003 | } 1004 | }) 1005 | }) 1006 | 1007 | it("shows completions for target string", () => { 1008 | useStateMachine({ 1009 | initial: "a", 1010 | states: { 1011 | a: { 1012 | on: { 1013 | // @ts-expect-error 1014 | X: " " 1015 | // ^| 1016 | } 1017 | }, 1018 | b: {}, 1019 | c: {} 1020 | } 1021 | }) 1022 | 1023 | expect(query().completions).toStrictEqual(["a", "b", "c"]) 1024 | }) 1025 | 1026 | describe("Machine.Definition.Transition['target']", () => { 1027 | it("expects target string", () => { 1028 | useStateMachine({ 1029 | initial: "a", 1030 | states: { 1031 | a: { 1032 | on: { 1033 | X: { 1034 | target: "b" 1035 | } 1036 | } 1037 | }, 1038 | b: {}, 1039 | c: {} 1040 | } 1041 | }) 1042 | 1043 | useStateMachine({ 1044 | initial: "a", 1045 | states: { 1046 | a: { 1047 | on: { 1048 | X: { 1049 | // @ts-expect-error 1050 | target: "" 1051 | } 1052 | } 1053 | }, 1054 | b: {}, 1055 | c: {} 1056 | } 1057 | }) 1058 | }) 1059 | 1060 | it("shows completions for target string", () => { 1061 | useStateMachine({ 1062 | initial: "a", 1063 | states: { 1064 | a: { 1065 | on: { 1066 | X: { 1067 | // @ts-expect-error 1068 | target: " " 1069 | // ^| 1070 | } 1071 | } 1072 | }, 1073 | b: {}, 1074 | c: {} 1075 | } 1076 | }) 1077 | 1078 | expect(query().completions).toStrictEqual(["a", "b", "c"]) 1079 | }) 1080 | }) 1081 | 1082 | describe("Machine.Definition.Transition['guard']", () => { 1083 | useStateMachine({ 1084 | schema: { 1085 | events: { 1086 | X: t<{ foo: string }>(), 1087 | Y: t<{}>() 1088 | } 1089 | }, 1090 | initial: "a", 1091 | context: { foo: 1 }, 1092 | states: { 1093 | a: { 1094 | on: { 1095 | X: { 1096 | target: "a", 1097 | guard: parameter => { 1098 | A.test(A.areEqual< 1099 | typeof parameter, 1100 | { context: { foo: number } 1101 | , event: { type: "X", foo: string } 1102 | } 1103 | >()) 1104 | 1105 | return true; 1106 | } 1107 | } 1108 | } 1109 | }, 1110 | b: { 1111 | on: { 1112 | Y: { 1113 | target: "c", 1114 | // @ts-expect-error 1115 | guard: () => "" 1116 | } 1117 | } 1118 | }, 1119 | c: {} 1120 | } 1121 | }) 1122 | }) 1123 | }) 1124 | }) 1125 | 1126 | describe("UseStateMachine", () => { 1127 | let [state, send] = useStateMachine({ 1128 | schema: { 1129 | events: { 1130 | X: t<{ foo: number }>(), 1131 | Y: t<{ bar?: number }>(), 1132 | Z: t<{}>() 1133 | }, 1134 | context: t<{ foo?: number }>() 1135 | }, 1136 | context: {}, 1137 | initial: "a", 1138 | states: { 1139 | a: { 1140 | on: { 1141 | X: "b", 1142 | } 1143 | }, 1144 | b: { 1145 | on: { 1146 | Y: "a" 1147 | } 1148 | } 1149 | }, 1150 | on: { 1151 | Z: "a" 1152 | } 1153 | }) 1154 | 1155 | describe("Machine.State", () => { 1156 | A.test(A.areEqual< 1157 | typeof state, 1158 | | { value: "a" 1159 | , context: { foo?: number } 1160 | , event: 1161 | | { type: "$$initial" } 1162 | | { type: "Y", bar?: number } 1163 | | { type: "Z" } 1164 | , nextEventsT: ("X" | "Z")[] 1165 | , nextEvents: ("X" | "Y" | "Z")[] 1166 | } 1167 | | { value: "b" 1168 | , context: { foo?: number } 1169 | , event: { type: "X", foo: number } 1170 | , nextEventsT: ("Y" | "Z")[] 1171 | , nextEvents: ("X" | "Y" | "Z")[] 1172 | } 1173 | >()) 1174 | }) 1175 | 1176 | describe("Machine.Send", () => { 1177 | A.test(A.areEqual< 1178 | typeof send, 1179 | { ( sendable: 1180 | | { type: "X", foo: number } 1181 | | { type: "Y", bar?: number } 1182 | | { type: "Z" } 1183 | ): void 1184 | , ( sendable: 1185 | | "Y" 1186 | | "Z" 1187 | ): void 1188 | } 1189 | >()) 1190 | }) 1191 | }) 1192 | 1193 | describe("Machine.Definition.FromTypeParamter", () => { 1194 | let [state, send] = useStateMachine({ 1195 | context: { toggleCount: 0 }, 1196 | initial: "inactive", 1197 | states: { 1198 | inactive: { 1199 | on: { TOGGLE: "active" }, 1200 | }, 1201 | active: { 1202 | on: { TOGGLE: "inactive" }, 1203 | effect({ setContext }) { 1204 | setContext(c => ({ toggleCount: c.toggleCount + 1 })); 1205 | }, 1206 | }, 1207 | }, 1208 | }) 1209 | 1210 | A.test(A.areEqual< 1211 | typeof state, 1212 | | { value: "inactive" 1213 | , context: { toggleCount: number } 1214 | , event: 1215 | | { type: "$$initial" } 1216 | | { type: "TOGGLE" } 1217 | , nextEventsT: "TOGGLE"[] 1218 | , nextEvents: "TOGGLE"[] 1219 | } 1220 | | { value: "active" 1221 | , context: { toggleCount: number } 1222 | , event: { type: "TOGGLE" } 1223 | , nextEventsT: "TOGGLE"[] 1224 | , nextEvents: "TOGGLE"[] 1225 | } 1226 | >()) 1227 | 1228 | A.test(A.areEqual< 1229 | typeof send, 1230 | { (sendable: { type: "TOGGLE" }): void 1231 | , (sendable: "TOGGLE"): void 1232 | } 1233 | >()) 1234 | }) 1235 | 1236 | describe("fix(Machine.State['nextEvents']): only normalize don't widen", () => { 1237 | let [state] = useStateMachine({ 1238 | schema: { 1239 | events: { Y: t<{}>() } 1240 | }, 1241 | context: {}, 1242 | initial: "a", 1243 | states: { 1244 | a: { on: { X: "b" } }, 1245 | b: {} 1246 | } 1247 | }) 1248 | 1249 | A.test(A.areEqual()) 1250 | }) 1251 | 1252 | describe("workaround for #65", () => { 1253 | let [_, send] = useStateMachine({ 1254 | schema: { 1255 | events: { 1256 | A: t<{ value: string }>() 1257 | } 1258 | }, 1259 | initial: "a", 1260 | states: { 1261 | a: { 1262 | on: { 1263 | B: "a" 1264 | } 1265 | } 1266 | } 1267 | }) 1268 | 1269 | A.test(A.areEqual< 1270 | typeof send, 1271 | { (sendable: { type: "A", value: string } | { type: "B" }): void 1272 | , (sendable: "B"): void 1273 | } 1274 | >()) 1275 | }) 1276 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "test"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "moduleResolution": "node", 16 | "jsx": "react", 17 | "esModuleInterop": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noEmit": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /twoslash-tester/generate.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { twoslasher } = require("@typescript/twoslash") 4 | const path = require("path") 5 | const fs = require("fs/promises") 6 | const fsOld = require("fs"); 7 | const { EOL } = require("os") 8 | 9 | let TEST_DIR = path.join(__dirname, "..", "test"); 10 | let TEST_FILENAME = "types.twoslash-test.ts"; 11 | 12 | async function generate (){ 13 | process.stdout.write("generating... ") 14 | let source = await fs.readFile(path.join(TEST_DIR, TEST_FILENAME), "utf8"); 15 | let GLOBALS = [ 16 | "declare const global: any;", 17 | "declare const describe: any;", 18 | "declare const it: any;", 19 | "declare const expect: any;" 20 | ].join(EOL) + EOL; 21 | 22 | let twoSlashQueries = minimalTwoSlashQueries(twoslasher 23 | (GLOBALS + source, "ts", { vfsRoot: TEST_DIR } 24 | )); 25 | 26 | let { imports, body } = parseSource(source) 27 | let generatedSource = 28 | imports + EOL + 29 | "// @ts-ignore" + EOL + 30 | "global.twoSlashQueries = getTwoSlashQueries()" + EOL + 31 | body + EOL + 32 | `function getTwoSlashQueries() { 33 | return ${JSON.stringify(twoSlashQueries, null, " ")} 34 | }` 35 | 36 | await fs.writeFile( 37 | path.join(TEST_DIR, TEST_FILENAME.replace("twoslash-", "")), 38 | generatedSource 39 | ) 40 | process.stdout.write("done.\n") 41 | } 42 | generate(); 43 | 44 | /** 45 | * @type { (result: import("@typescript/twoslash").TwoSlashReturn) => 46 | * { text?: string 47 | * , completions?: string[] 48 | * }[] 49 | * } 50 | */ 51 | function minimalTwoSlashQueries(result) { 52 | return result.queries 53 | .map(q => 54 | q.kind !== "completions" ? q : 55 | q.completions.some(c => c.name === "globalThis") 56 | ? { ...q, completions: [] } 57 | : q 58 | ) 59 | .map(q => ({ 60 | text: q.text, 61 | completions: 62 | q.completions?.map(c => c.name) 63 | })) 64 | } 65 | 66 | 67 | /** 68 | * @param source {string} 69 | */ 70 | function parseSource(source) { 71 | return source 72 | .split(EOL) 73 | .reduce((r, l) => 74 | l.startsWith("import") || 75 | (r.body === "" && l.startsWith("/*")) // to include eslint comment 76 | ? { ...r, imports: r.imports + l + EOL } 77 | : { ...r, body: r.body + l + EOL }, 78 | { imports: "", body: "" } 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: API 4 | --- 5 | 6 | # API 7 | 8 | ```ts 9 | const [state, send] = useStateMachine(/* State Machine Definition */); 10 | ``` 11 | 12 | `useStateMachine` takes a JavaScript object as the state machine definition. It returns an array consisting of a `current machine state` object and a `send` function to trigger transitions. 13 | 14 | ## state 15 | 16 | The machine's `state` consists of 4 properties: `value`, `event`, `nextEvents` and `context`. 17 | 18 | `value` (string): Returns the name of the current state. 19 | 20 | `event` (`{type: string}`; Optional): The name of the last sent event that led to this state. 21 | 22 | `nextEvents` (`string[]`): An array with the names of available events to trigger transitions from this state. 23 | 24 | `context`: The state machine extended state. See "Extended State" below. 25 | 26 | ## Send events 27 | 28 | `send` takes an event as argument, provided in shorthand string format (e.g. "TOGGLE") or as an event object (e.g. `{ type: "TOGGLE" }`) 29 | 30 | If the current state accepts this event, and it is allowed (see guard), it will change the state machine state and execute effects. 31 | 32 | You can also send additional data with your event using the object notation (e.g. `{ type: "UPDATE" value: 10 }`). Check [schema](#schema-context--event-typing) for more information about strong typing the additional data. 33 | 34 | ## State Machine definition 35 | 36 | | Key | Required | Description | 37 | | ----------- | ---- |----------- | 38 | | verbose | | If true, will log every context & state changes. Log messages will be stripped out in the production build. | 39 | | schema | | For usage with TypeScript only. Optional strongly-typed context & events. More on schema [below](#schema-context--event-typing) | 40 | | context | | Context is the machine's extended state. More on extended state [below](#extended-state-context) | 41 | | initial | * | The initial state node this machine should be in | 42 | | states | * | Define the possible finite states the state machine can be in. | 43 | 44 | ## Defining States 45 | 46 | A finite state machine can be in only one of a finite number of states at any given time. As an application is interacted with, events cause it to change state. 47 | 48 | States are defined with the state name as a key and an object with two possible keys: `on` (which events this state responds to) and `effect` (run arbitrary code when entering or exiting this state): 49 | 50 | ### On (Events & transitions) 51 | 52 | Describes which events this state responds to (and to which other state the machine should transition to when this event is sent): 53 | 54 | ```ts twoslash 55 | import useStateMachine from '@cassiozen/usestatemachine'; 56 | // ---cut--- 57 | const [state, send] = useStateMachine({ 58 | initial: 'active', 59 | states: { 60 | inactive: { 61 | on: { 62 | TOGGLE: 'active', 63 | } 64 | }, 65 | active: { 66 | on: { 67 | TOGGLE: 'inactive', 68 | } 69 | }, 70 | } 71 | }) 72 | ``` 73 | 74 | The event definition can also use the extended, object syntax, which allows for more control over the transition (like adding guards): 75 | 76 | ```ts 77 | on: { 78 | TOGGLE: { 79 | target: 'active', 80 | }, 81 | }; 82 | ``` 83 | 84 | #### Guards 85 | 86 | Guards are functions that run before actually making the state transition: If the guard returns false the transition will be denied. 87 | 88 | ```ts twoslash 89 | import useStateMachine from '@cassiozen/usestatemachine'; 90 | // ---cut--- 91 | const [state, send] = useStateMachine({ 92 | initial: 'inactive', 93 | states: { 94 | inactive: { 95 | on: { 96 | TOGGLE: { 97 | target: 'active', 98 | guard({ context, event }) { 99 | // Return a boolean to allow or block the transition 100 | return false; 101 | }, 102 | }, 103 | }, 104 | }, 105 | active: { 106 | on: { TOGGLE: 'inactive' }, 107 | }, 108 | }, 109 | }); 110 | ``` 111 | 112 | The guard function receives an object with the current context and the event. The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`). 113 | 114 | ### Effects (entry/exit callbacks) 115 | 116 | Effects are triggered when the state machine enters a given state. If you return a function from your effect, it will be invoked when leaving that state (similarly to how useEffect works in React). 117 | 118 | ```ts twoslash 119 | import useStateMachine from '@cassiozen/usestatemachine'; 120 | // ---cut--- 121 | const [state, send] = useStateMachine({ 122 | initial: 'active', 123 | states: { 124 | active: { 125 | on: { TOGGLE: 'inactive' }, 126 | effect({ send, setContext, event, context }) { 127 | console.log('Just entered the Active state'); 128 | return () => console.log('Just Left the Active state'); 129 | }, 130 | }, 131 | inactive: {}, 132 | }, 133 | }); 134 | ``` 135 | 136 | The effect function receives an object as parameter with four keys: 137 | 138 | - `send`: Takes an event as argument, provided in shorthand string format (e.g. "TOGGLE") or as an event object (e.g. `{ type: "TOGGLE" }`) 139 | - `setContext`: Takes an updater function as parameter to set a new context (more on context below). Returns an object with `send`, so you can set the context and send an event on a single line. 140 | - `event`: The event that triggered a transition to this state. (The event parameter always uses the object format (e.g. `{ type: 'TOGGLE' }`).). 141 | - `context` The context at the time the effect runs. 142 | 143 | In this example, the state machine will always send the "RETRY" event when entering the error state: 144 | 145 | ```typescript 146 | const [state, send] = useStateMachine({ 147 | initial: 'loading', 148 | states: { 149 | /* Other states here... */ 150 | error: { 151 | on: { 152 | RETRY: 'load', 153 | }, 154 | effect({ send }) { 155 | send('RETRY'); 156 | }, 157 | }, 158 | }, 159 | }); 160 | ``` 161 | 162 | ## Extended state (context) 163 | 164 | Besides the finite number of states, the state machine can have extended state (known as context). 165 | 166 | You can provide the initial context value in the state machine definition, then use the `setContext` function within your effects to change the context: 167 | 168 | ```ts twoslash 169 | import useStateMachine from '@cassiozen/usestatemachine'; 170 | // ---cut--- 171 | const [state, send] = useStateMachine({ 172 | context: { toggleCount: 0 }, 173 | initial: 'inactive', 174 | states: { 175 | inactive: { 176 | on: { TOGGLE: 'active' }, 177 | }, 178 | active: { 179 | on: { TOGGLE: 'inactive' }, 180 | effect({ setContext }) { 181 | setContext(context => ({ toggleCount: context.toggleCount + 1 })); 182 | }, 183 | }, 184 | }, 185 | }); 186 | 187 | console.log(state); // { context: { toggleCount: 0 }, value: 'inactive', nextEvents: ['TOGGLE'] } 188 | 189 | send('TOGGLE'); 190 | 191 | console.log(state); // { context: { toggleCount: 1 }, value: 'active', nextEvents: ['TOGGLE'] } 192 | ``` 193 | 194 | ## Schema: Context & Event Typing 195 | 196 | TypeScript will automatically infer your context type; event types are generated automatically. 197 | 198 | Still, there are situations where you might want explicit control over the `context` and `event` types: You can provide you own typing using the `t` whithin `schema`: 199 | 200 | *Typed Context example* 201 | 202 | ```ts twoslash 203 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 204 | 205 | const [state, send] = useStateMachine({ 206 | schema: { 207 | context: t<{ toggleCount: number }>() 208 | }, 209 | context: { toggleCount: 0 }, 210 | initial: 'inactive', 211 | states: { 212 | inactive: { 213 | on: { TOGGLE: 'active' }, 214 | }, 215 | active: { 216 | on: { TOGGLE: 'inactive' }, 217 | effect({ setContext }) { 218 | setContext(context => ({ toggleCount: context.toggleCount + 1 })); 219 | }, 220 | }, 221 | }, 222 | }); 223 | ``` 224 | 225 | *Typed Events* 226 | 227 | 228 | All events are type-infered by default, both in the string notation (`send("UPDATE")`) and the object notation (`send({ type: "UPDATE"})`). 229 | 230 | If you want, though, you can augment an already typed event to include arbitrary data (which can be useful to provide values to be used inside effects or to update the context). Example: 231 | 232 | ```ts twoslash 233 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 234 | // ---cut--- 235 | const [machine, send] = useStateMachine({ 236 | schema: { 237 | context: t<{ timeout?: number }>(), 238 | events: { 239 | PING: t<{ value: number }>() 240 | } 241 | }, 242 | context: {timeout: undefined}, 243 | initial: 'waiting', 244 | states: { 245 | waiting: { 246 | on: { 247 | PING: 'pinged' 248 | } 249 | }, 250 | pinged: { 251 | effect({ setContext, event }) { 252 | setContext(c => ({ timeout: event?.value ?? 0 })); 253 | }, 254 | } 255 | }, 256 | }); 257 | 258 | send({ type: 'PING', value: 150 }) 259 | ``` 260 | 261 | **Note** that you don't need to declare all your events in the schema, only the ones you're adding arbitrary keys and values. 262 | -------------------------------------------------------------------------------- /website/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Getting Started 4 | --- 5 | 6 | # Getting Started 7 | 8 | ## install 9 | 10 | ```shell 11 | npm install @cassiozen/usestatemachine 12 | ``` 13 | 14 | ## Sample Usage 15 | 16 | ```ts twoslash 17 | import useStateMachine from '@cassiozen/usestatemachine'; 18 | // ---cut--- 19 | const [state, send] = useStateMachine({ 20 | initial: 'inactive', 21 | states: { 22 | inactive: { 23 | on: { TOGGLE: 'active' }, 24 | }, 25 | active: { 26 | on: { TOGGLE: 'inactive' }, 27 | effect() { 28 | console.log('Just entered the Active state'); 29 | // Same cleanup pattern as `useEffect`: 30 | // If you return a function, it will run when exiting the state. 31 | return () => console.log('Just Left the Active state'); 32 | }, 33 | }, 34 | }, 35 | }); 36 | 37 | console.log(state); // { value: 'inactive', nextEvents: ['TOGGLE'] } 38 | 39 | // Refers to the TOGGLE event name for the state we are currently in. 40 | 41 | send('TOGGLE'); 42 | 43 | // Logs: Just entered the Active state 44 | 45 | console.log(state); // { value: 'active', nextEvents: ['TOGGLE'] } 46 | ``` 47 | -------------------------------------------------------------------------------- /website/docs/upgrading-from-0xx.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: Upgrading from 0.x.x 4 | --- 5 | 6 | 7 | Version 1.0 introduces a few API changes: 8 | 9 | - No more curried function: Extended state is now declared within the State Machine configuration object 10 | - New signature for `effects` and `guards` 11 | 12 | ## New Context Configuration: 13 | 14 | **Before** 15 | 16 | ```ts 17 | const [state, send] = useStateMachine(/* Context */)(/* Configuration */); 18 | ``` 19 | 20 | **After** 21 | 22 | ```ts 23 | const [state, send] = useStateMachine(/* Configuration (including context) */); 24 | ``` 25 | 26 | 27 | 28 | ## `effects` and `guards`: 29 | 30 | - Both functions receive a single object with multiple keys instead of multiple parameters. 31 | - Effects now receive the context. 32 | - The context updater function inside `effect` is now called `setContext` instead of `update`. 33 | 34 | Here's a diff between the fetch example on versions 0.x.x and 1.0.0: 35 | 36 | ![Diff](https://user-images.githubusercontent.com/33676/121916961-3d768580-ccfa-11eb-9099-d6ba74fd6018.png) 37 | 38 | 39 | -------------------------------------------------------------------------------- /website/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | 2 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 3 | module.exports = { 4 | title: "useStateMachine", 5 | tagline: "The <1 kb state machine hook for React", 6 | url: "https://cassiozen.github.io", 7 | baseUrl: "/", 8 | trailingSlash: true, 9 | onBrokenLinks: "throw", 10 | onBrokenMarkdownLinks: "warn", 11 | favicon: "img/favicon.svg", 12 | organizationName: "cassiozen", // Usually your GitHub org/user name. 13 | projectName: "useStateMachine", // Usually your repo name. 14 | themeConfig: { 15 | colorMode: { 16 | defaultMode: 'dark', 17 | disableSwitch: false, 18 | respectPrefersColorScheme: true, 19 | switchConfig: { 20 | darkIcon: '🌙', 21 | lightIcon: '\u2600', 22 | darkIconStyle: { 23 | marginLeft: '2px', 24 | }, 25 | lightIconStyle: { 26 | marginLeft: '1px', 27 | }, 28 | }, 29 | }, 30 | navbar: { 31 | title: "useStateMachine", 32 | logo: { 33 | alt: "useStateMachine Logo", 34 | src: "img/logo.png", 35 | }, 36 | items: [ 37 | { 38 | type: 'doc', 39 | docId: 'api', 40 | position: 'left', 41 | label: 'API Docs', 42 | }, 43 | { 44 | href: "https://github.com/cassiozen/useStateMachine", 45 | label: "GitHub", 46 | position: "right", 47 | }, 48 | ], 49 | }, 50 | 51 | 52 | }, 53 | presets: [ 54 | [ 55 | "@docusaurus/preset-classic", 56 | { 57 | docs: { 58 | editUrl: 59 | "https://github.com/cassiozen/useStateMachine/edit/main/docs/", 60 | }, 61 | theme: { 62 | customCss: require.resolve("./src/css/custom.css"), 63 | }, 64 | }, 65 | ], 66 | [ 67 | "docusaurus-preset-shiki-twoslash", 68 | { 69 | themes: ["min-light", "github-dark"], 70 | }, 71 | ], 72 | ], 73 | plugins: [require.resolve('docusaurus-lunr-search')], 74 | }; 75 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-state-machine", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@cassiozen/usestatemachine": "^1.0.0-beta.2", 18 | "@docusaurus/core": "2.0.0-beta.4", 19 | "@docusaurus/preset-classic": "2.0.0-beta.4", 20 | "@mdx-js/react": "^1.6.21", 21 | "@svgr/webpack": "^5.5.0", 22 | "docusaurus-lunr-search": "^2.1.14", 23 | "docusaurus-preset-shiki-twoslash": "^1.1.19", 24 | "file-loader": "^6.2.0", 25 | "react": "^17.0.1", 26 | "react-dom": "^17.0.1", 27 | "url-loader": "^4.1.1" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.5%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | 10 | 11 | .docusaurus-highlight-code-line { 12 | background-color: rgba(0, 0, 0, 0.1); 13 | display: block; 14 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 15 | padding: 0 var(--ifm-pre-padding); 16 | } 17 | 18 | html[data-theme='dark'] .docusaurus-highlight-code-line { 19 | background-color: rgba(0, 0, 0, 0.3); 20 | } 21 | 22 | @media only screen and (max-device-width: 490px) and (orientation : portrait){ 23 | .hero__title { 24 | font-size: 2rem; 25 | } 26 | } 27 | 28 | .navbar__search { 29 | padding-left: 7px; 30 | } 31 | 32 | 33 | [data-theme="light"] .shiki.github-dark { 34 | display: none; 35 | } 36 | 37 | [data-theme="dark"] .shiki.min-light { 38 | display: none; 39 | } 40 | -------------------------------------------------------------------------------- /website/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Layout from "@theme/Layout"; 3 | import Link from "@docusaurus/Link"; 4 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 5 | import useBaseUrl from "@docusaurus/useBaseUrl"; 6 | import styles from "./index.module.css"; 7 | // eslint-disable-next-line no-unused-vars 8 | import useStateMachine, {t} from '@cassiozen/usestatemachine'; 9 | 10 | function Home() { 11 | const context = useDocusaurusContext(); 12 | const { siteConfig = {} } = context; 13 | 14 | return ( 15 | 16 |

17 |
18 | useStateMachine logo 23 |

{siteConfig.title}

24 |

{siteConfig.tagline}

25 |
26 | 30 | Get Started  → 31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | Batteries Included 45 |

46 | Batteries Included 47 |

48 |

49 | 50 | Despite the tiny size, useStateMachine is a feature-complete 51 | {' '}State Machine library, including features like Entry/Exit callbacks, Guarded transitions 52 | & Extended State (Context) 53 | 54 |

55 |
56 |
57 | Amazing TypeScript experience 62 |

63 | Amazing TypeScript experience 64 |

65 |

66 | 67 | Focus on automatic type inference (auto completion for both TypeScript & JavaScript users 68 | without having to manually define the typings) while giving you the option to specify 69 | and augment the types for context & events. 70 | 71 | 72 |

73 |
74 |
75 | Made for React 80 |

81 | Made for React 82 |

83 |

84 | 85 | Instead of introducing many new concepts, useStateMachine follow idiomatic React patterns you and 86 | your team are already familiar with.
87 | The library itself is actually a thin wrapper around React's useReducer & useEffect. 88 | 89 |

90 |
91 |
92 |
93 |
94 |
95 | 96 | ); 97 | } 98 | 99 | export default Home; 100 | -------------------------------------------------------------------------------- /website/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | background-color: #2b3137; 14 | color: #fff; 15 | } 16 | 17 | .heroBannerLogo { 18 | max-width: 220px; 19 | } 20 | 21 | @media screen and (max-width: 966px) { 22 | .heroBanner { 23 | padding: 2rem; 24 | } 25 | } 26 | 27 | .buttons { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | } 32 | 33 | .section { 34 | padding: 72px 0; 35 | } 36 | 37 | .featureImage { 38 | margin: 0 auto; 39 | max-height: 128px; 40 | max-width: 60%; 41 | } 42 | 43 | .featureHeading { 44 | font-size: var(--ifm-h3-font-size); 45 | padding-top: 1rem; 46 | } -------------------------------------------------------------------------------- /website/src/theme/SearchBar/algolia.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /* Bottom border of each suggestion */ 9 | .algolia-docsearch-suggestion { 10 | border-bottom-color: #3a3dd1; 11 | } 12 | /* Main category headers */ 13 | .algolia-docsearch-suggestion--category-header { 14 | background-color: #4b54de; 15 | } 16 | /* Highlighted search terms */ 17 | .algolia-docsearch-suggestion--highlight { 18 | color: #3a33d1; 19 | } 20 | /* Highligted search terms in the main category headers */ 21 | .algolia-docsearch-suggestion--category-header 22 | .algolia-docsearch-suggestion--highlight { 23 | background-color: #4d47d5; 24 | } 25 | /* Currently selected suggestion */ 26 | .aa-cursor .algolia-docsearch-suggestion--content { 27 | color: #272296; 28 | } 29 | .aa-cursor .algolia-docsearch-suggestion { 30 | background: #ebebfb; 31 | } 32 | 33 | /* For bigger screens, when displaying results in two columns */ 34 | @media (min-width: 768px) { 35 | /* Bottom border of each suggestion */ 36 | .algolia-docsearch-suggestion { 37 | border-bottom-color: #7671df; 38 | } 39 | /* Left column, with secondary category header */ 40 | .algolia-docsearch-suggestion--subcategory-column { 41 | border-right-color: #7671df; 42 | color: #4e4726; 43 | } 44 | } 45 | 46 | .searchbox { 47 | display: inline-block; 48 | position: relative; 49 | width: 200px; 50 | height: 32px !important; 51 | white-space: nowrap; 52 | box-sizing: border-box; 53 | visibility: visible !important; 54 | } 55 | 56 | .searchbox .algolia-autocomplete { 57 | display: block; 58 | width: 100%; 59 | height: 100%; 60 | } 61 | 62 | .searchbox__wrapper { 63 | width: 100%; 64 | height: 100%; 65 | z-index: 999; 66 | position: relative; 67 | } 68 | 69 | .searchbox__input { 70 | display: inline-block; 71 | box-sizing: border-box; 72 | -webkit-transition: box-shadow 0.4s ease, background 0.4s ease; 73 | transition: box-shadow 0.4s ease, background 0.4s ease; 74 | border: 0; 75 | border-radius: 16px; 76 | box-shadow: inset 0 0 0 1px #cccccc; 77 | background: #ffffff !important; 78 | padding: 0; 79 | padding-right: 26px; 80 | padding-left: 32px; 81 | width: 100%; 82 | height: 100%; 83 | vertical-align: middle; 84 | white-space: normal; 85 | font-size: 12px; 86 | -webkit-appearance: none; 87 | -moz-appearance: none; 88 | appearance: none; 89 | } 90 | 91 | .searchbox__input::-webkit-search-decoration, 92 | .searchbox__input::-webkit-search-cancel-button, 93 | .searchbox__input::-webkit-search-results-button, 94 | .searchbox__input::-webkit-search-results-decoration { 95 | display: none; 96 | } 97 | 98 | .searchbox__input:hover { 99 | box-shadow: inset 0 0 0 1px #b3b3b3; 100 | } 101 | 102 | .searchbox__input:focus, 103 | .searchbox__input:active { 104 | outline: 0; 105 | box-shadow: inset 0 0 0 1px #aaaaaa; 106 | background: #ffffff; 107 | } 108 | 109 | .searchbox__input::-webkit-input-placeholder { 110 | color: #aaaaaa; 111 | } 112 | 113 | .searchbox__input::-moz-placeholder { 114 | color: #aaaaaa; 115 | } 116 | 117 | .searchbox__input:-ms-input-placeholder { 118 | color: #aaaaaa; 119 | } 120 | 121 | .searchbox__input::placeholder { 122 | color: #aaaaaa; 123 | } 124 | 125 | .searchbox__submit { 126 | position: absolute; 127 | top: 0; 128 | margin: 0; 129 | border: 0; 130 | border-radius: 16px 0 0 16px; 131 | background-color: rgba(69, 142, 225, 0); 132 | padding: 0; 133 | width: 32px; 134 | height: 100%; 135 | vertical-align: middle; 136 | text-align: center; 137 | font-size: inherit; 138 | -webkit-user-select: none; 139 | -moz-user-select: none; 140 | -ms-user-select: none; 141 | user-select: none; 142 | right: inherit; 143 | left: 0; 144 | } 145 | 146 | .searchbox__submit::before { 147 | display: inline-block; 148 | margin-right: -4px; 149 | height: 100%; 150 | vertical-align: middle; 151 | content: ''; 152 | } 153 | 154 | .searchbox__submit:hover, 155 | .searchbox__submit:active { 156 | cursor: pointer; 157 | } 158 | 159 | .searchbox__submit:focus { 160 | outline: 0; 161 | } 162 | 163 | .searchbox__submit svg { 164 | width: 14px; 165 | height: 14px; 166 | vertical-align: middle; 167 | fill: #6d7e96; 168 | } 169 | 170 | .searchbox__reset { 171 | display: block; 172 | position: absolute; 173 | top: 8px; 174 | right: 8px; 175 | margin: 0; 176 | border: 0; 177 | background: none; 178 | cursor: pointer; 179 | padding: 0; 180 | font-size: inherit; 181 | -webkit-user-select: none; 182 | -moz-user-select: none; 183 | -ms-user-select: none; 184 | user-select: none; 185 | fill: rgba(0, 0, 0, 0.5); 186 | } 187 | 188 | .searchbox__reset.hide { 189 | display: none; 190 | } 191 | 192 | .searchbox__reset:focus { 193 | outline: 0; 194 | } 195 | 196 | .searchbox__reset svg { 197 | display: block; 198 | margin: 4px; 199 | width: 8px; 200 | height: 8px; 201 | } 202 | 203 | .searchbox__input:valid ~ .searchbox__reset { 204 | display: block; 205 | -webkit-animation-name: sbx-reset-in; 206 | animation-name: sbx-reset-in; 207 | -webkit-animation-duration: 0.15s; 208 | animation-duration: 0.15s; 209 | } 210 | 211 | @-webkit-keyframes sbx-reset-in { 212 | 0% { 213 | -webkit-transform: translate3d(-20%, 0, 0); 214 | transform: translate3d(-20%, 0, 0); 215 | opacity: 0; 216 | } 217 | 100% { 218 | -webkit-transform: none; 219 | transform: none; 220 | opacity: 1; 221 | } 222 | } 223 | 224 | @keyframes sbx-reset-in { 225 | 0% { 226 | -webkit-transform: translate3d(-20%, 0, 0); 227 | transform: translate3d(-20%, 0, 0); 228 | opacity: 0; 229 | } 230 | 100% { 231 | -webkit-transform: none; 232 | transform: none; 233 | opacity: 1; 234 | } 235 | } 236 | 237 | .algolia-autocomplete .ds-dropdown-menu:before { 238 | display: block; 239 | position: absolute; 240 | content: ''; 241 | width: 14px; 242 | height: 14px; 243 | background: #373940; 244 | z-index: 1000; 245 | top: -7px; 246 | border-top: 1px solid #373940; 247 | border-right: 1px solid #373940; 248 | -webkit-transform: rotate(-45deg); 249 | transform: rotate(-45deg); 250 | border-radius: 2px; 251 | } 252 | 253 | .algolia-autocomplete .ds-dropdown-menu { 254 | box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2), 0 2px 3px 0 rgba(0, 0, 0, 0.1); 255 | } 256 | 257 | @media (min-width: 601px) { 258 | .algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu { 259 | right: 0 !important; 260 | left: inherit !important; 261 | } 262 | 263 | .algolia-autocomplete.algolia-autocomplete-right .ds-dropdown-menu:before { 264 | right: 48px; 265 | } 266 | 267 | .algolia-autocomplete .ds-dropdown-menu { 268 | position: relative; 269 | top: -6px; 270 | border-radius: 4px; 271 | margin: 6px 0 0; 272 | padding: 0; 273 | text-align: left; 274 | height: auto; 275 | position: relative; 276 | background: transparent; 277 | border: none; 278 | z-index: 999; 279 | max-width: 600px; 280 | min-width: 500px; 281 | } 282 | } 283 | 284 | @media (max-width: 600px) { 285 | .algolia-autocomplete .ds-dropdown-menu { 286 | z-index: 100; 287 | position: fixed !important; 288 | top: 50px !important; 289 | left: auto !important; 290 | right: 1rem !important; 291 | width: 600px; 292 | max-width: calc(100% - 2rem); 293 | max-height: calc(100% - 5rem); 294 | display: block; 295 | } 296 | 297 | .algolia-autocomplete .ds-dropdown-menu:before { 298 | right: 6rem; 299 | } 300 | } 301 | 302 | .algolia-autocomplete .ds-dropdown-menu .ds-suggestions { 303 | position: relative; 304 | z-index: 1000; 305 | } 306 | 307 | .algolia-autocomplete .ds-dropdown-menu .ds-suggestion { 308 | cursor: pointer; 309 | } 310 | 311 | .algolia-autocomplete .ds-dropdown-menu [class^='ds-dataset-'] { 312 | position: relative; 313 | border-radius: 4px; 314 | overflow: auto; 315 | padding: 0; 316 | background: #ffffff; 317 | } 318 | 319 | .algolia-autocomplete .ds-dropdown-menu * { 320 | box-sizing: border-box; 321 | } 322 | 323 | .algolia-autocomplete .algolia-docsearch-suggestion { 324 | display: block; 325 | position: relative; 326 | padding: 0; 327 | overflow: hidden; 328 | text-decoration: none; 329 | } 330 | 331 | .algolia-autocomplete .ds-cursor .algolia-docsearch-suggestion--wrapper { 332 | background: #f1f1f1; 333 | box-shadow: inset -2px 0 0 #61dafb; 334 | } 335 | 336 | .algolia-autocomplete .algolia-docsearch-suggestion--highlight { 337 | background: #ffe564; 338 | padding: 0.1em 0.05em; 339 | } 340 | 341 | .algolia-autocomplete 342 | .algolia-docsearch-suggestion--category-header 343 | .algolia-docsearch-suggestion--category-header-lvl0 344 | .algolia-docsearch-suggestion--highlight, 345 | .algolia-autocomplete 346 | .algolia-docsearch-suggestion--category-header 347 | .algolia-docsearch-suggestion--category-header-lvl1 348 | .algolia-docsearch-suggestion--highlight { 349 | color: inherit; 350 | background: inherit; 351 | } 352 | 353 | .algolia-autocomplete 354 | .algolia-docsearch-suggestion--text 355 | .algolia-docsearch-suggestion--highlight { 356 | padding: 0 0 1px; 357 | background: inherit; 358 | box-shadow: inset 0 -2px 0 0 rgba(69, 142, 225, 0.8); 359 | color: inherit; 360 | } 361 | 362 | .algolia-autocomplete .algolia-docsearch-suggestion--content { 363 | display: block; 364 | float: right; 365 | width: 70%; 366 | position: relative; 367 | padding: 5.33333px 0 5.33333px 10.66667px; 368 | cursor: pointer; 369 | } 370 | 371 | .algolia-autocomplete .algolia-docsearch-suggestion--content:before { 372 | content: ''; 373 | position: absolute; 374 | display: block; 375 | top: 0; 376 | height: 100%; 377 | width: 1px; 378 | background: #ececec; 379 | left: -1px; 380 | } 381 | 382 | .algolia-autocomplete .algolia-docsearch-suggestion--category-header { 383 | position: relative; 384 | display: none; 385 | font-size: 14px; 386 | letter-spacing: 0.08em; 387 | font-weight: 700; 388 | background-color: #373940; 389 | text-transform: uppercase; 390 | color: #fff; 391 | margin: 0; 392 | padding: 5px 8px; 393 | } 394 | 395 | .algolia-autocomplete .algolia-docsearch-suggestion--wrapper { 396 | background-color: #fff; 397 | width: 100%; 398 | float: left; 399 | padding: 8px 0 0 0; 400 | } 401 | 402 | .algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column { 403 | float: left; 404 | width: 30%; 405 | display: none; 406 | padding-left: 0; 407 | text-align: right; 408 | position: relative; 409 | padding: 5.33333px 10.66667px; 410 | color: #777; 411 | font-size: 0.9em; 412 | word-wrap: break-word; 413 | } 414 | 415 | .algolia-autocomplete .algolia-docsearch-suggestion--subcategory-column:before { 416 | content: ''; 417 | position: absolute; 418 | display: block; 419 | top: 0; 420 | height: 100%; 421 | width: 1px; 422 | background: #ececec; 423 | right: 0; 424 | } 425 | 426 | .algolia-autocomplete 427 | .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main 428 | .algolia-docsearch-suggestion--category-header, 429 | .algolia-autocomplete 430 | .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary { 431 | display: block; 432 | } 433 | 434 | .algolia-autocomplete 435 | .algolia-docsearch-suggestion--subcategory-column 436 | .algolia-docsearch-suggestion--highlight { 437 | background-color: inherit; 438 | color: inherit; 439 | } 440 | 441 | .algolia-autocomplete .algolia-docsearch-suggestion--subcategory-inline { 442 | display: none; 443 | } 444 | 445 | .algolia-autocomplete .algolia-docsearch-suggestion--title { 446 | margin-bottom: 4px; 447 | color: #02060c; 448 | font-size: 0.9em; 449 | font-weight: bold; 450 | } 451 | 452 | .algolia-autocomplete .algolia-docsearch-suggestion--text { 453 | display: block; 454 | line-height: 1.2em; 455 | font-size: 0.85em; 456 | color: #63676d; 457 | padding-right: 2px; 458 | } 459 | 460 | .algolia-autocomplete .algolia-docsearch-suggestion--no-results { 461 | width: 100%; 462 | padding: 8px 0; 463 | text-align: center; 464 | font-size: 1.2em; 465 | background-color: #373940; 466 | margin-top: -8px; 467 | } 468 | 469 | .algolia-autocomplete 470 | .algolia-docsearch-suggestion--no-results 471 | .algolia-docsearch-suggestion--text { 472 | color: #ffffff; 473 | margin-top: 4px; 474 | } 475 | 476 | .algolia-autocomplete .algolia-docsearch-suggestion--no-results::before { 477 | display: none; 478 | } 479 | 480 | .algolia-autocomplete .algolia-docsearch-suggestion code { 481 | padding: 1px 5px; 482 | font-size: 90%; 483 | border: none; 484 | color: #222222; 485 | background-color: #ebebeb; 486 | border-radius: 3px; 487 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 488 | monospace; 489 | } 490 | 491 | .algolia-autocomplete 492 | .algolia-docsearch-suggestion 493 | code 494 | .algolia-docsearch-suggestion--highlight { 495 | background: none; 496 | } 497 | 498 | .algolia-autocomplete 499 | .algolia-docsearch-suggestion.algolia-docsearch-suggestion__main 500 | .algolia-docsearch-suggestion--category-header { 501 | color: white; 502 | display: block; 503 | } 504 | 505 | .algolia-autocomplete 506 | .algolia-docsearch-suggestion.algolia-docsearch-suggestion__secondary 507 | .algolia-docsearch-suggestion--subcategory-column { 508 | display: block; 509 | } 510 | 511 | .algolia-autocomplete .algolia-docsearch-footer { 512 | background-color: #fff; 513 | width: 100%; 514 | height: 30px; 515 | z-index: 2000; 516 | float: right; 517 | font-size: 0; 518 | line-height: 0; 519 | } 520 | 521 | .algolia-autocomplete .algolia-docsearch-footer--logo { 522 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 130 18'%3E%3Cdefs%3E%3ClinearGradient id='a' x1='-36.87%25' x2='129.43%25' y1='134.94%25' y2='-27.7%25'%3E%3Cstop stop-color='%252300AEFF' offset='0%25'/%3E%3Cstop stop-color='%25233369E7' offset='100%25'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath fill='url(%2523a)' d='M59.4.02h13.3a2.37 2.37 0 0 1 2.38 2.37V15.6a2.37 2.37 0 0 1-2.38 2.36H59.4a2.37 2.37 0 0 1-2.38-2.36V2.38A2.37 2.37 0 0 1 59.4.02z'/%3E%3Cpath fill='%2523FFF' d='M66.26 4.56c-2.82 0-5.1 2.27-5.1 5.08 0 2.8 2.28 5.07 5.1 5.07 2.8 0 5.1-2.26 5.1-5.07 0-2.8-2.28-5.07-5.1-5.07zm0 8.65c-2 0-3.6-1.6-3.6-3.56 0-1.97 1.6-3.58 3.6-3.58 1.98 0 3.6 1.6 3.6 3.58a3.58 3.58 0 0 1-3.6 3.57zm0-6.4v2.66c0 .07.08.13.15.1l2.4-1.24c.04-.02.06-.1.03-.14a2.96 2.96 0 0 0-2.46-1.5c-.06 0-.1.05-.1.1zm-3.33-1.96l-.3-.3a.78.78 0 0 0-1.12 0l-.36.36a.77.77 0 0 0 0 1.1l.3.3c.05.05.13.04.17 0 .2-.25.4-.5.6-.7.23-.23.46-.43.7-.6.07-.04.07-.1.03-.16zm5-.8V3.4a.78.78 0 0 0-.78-.78h-1.83a.78.78 0 0 0-.78.78v.63c0 .07.06.12.14.1a5.74 5.74 0 0 1 1.58-.22c.52 0 1.04.07 1.54.2a.1.1 0 0 0 .13-.1z'/%3E%3Cpath fill='%2523182359' d='M102.16 13.76c0 1.46-.37 2.52-1.12 3.2-.75.67-1.9 1-3.44 1-.56 0-1.74-.1-2.67-.3l.34-1.7c.78.17 1.82.2 2.36.2.86 0 1.48-.16 1.84-.5.37-.36.55-.88.55-1.57v-.35a6.37 6.37 0 0 1-.84.3 4.15 4.15 0 0 1-1.2.17 4.5 4.5 0 0 1-1.6-.28 3.38 3.38 0 0 1-1.26-.82 3.74 3.74 0 0 1-.8-1.35c-.2-.54-.3-1.5-.3-2.2 0-.67.1-1.5.3-2.06a3.92 3.92 0 0 1 .9-1.43 4.12 4.12 0 0 1 1.45-.92 5.3 5.3 0 0 1 1.94-.37c.7 0 1.35.1 1.97.2a15.86 15.86 0 0 1 1.6.33v8.46zm-5.95-4.2c0 .9.2 1.88.6 2.3.4.4.9.62 1.53.62.34 0 .66-.05.96-.15a2.75 2.75 0 0 0 .73-.33V6.7a8.53 8.53 0 0 0-1.42-.17c-.76-.02-1.36.3-1.77.8-.4.5-.62 1.4-.62 2.23zm16.13 0c0 .72-.1 1.26-.32 1.85a4.4 4.4 0 0 1-.9 1.53c-.38.42-.85.75-1.4.98-.54.24-1.4.37-1.8.37-.43 0-1.27-.13-1.8-.36a4.1 4.1 0 0 1-1.4-.97 4.5 4.5 0 0 1-.92-1.52 5.04 5.04 0 0 1-.33-1.84c0-.72.1-1.4.32-2 .22-.6.53-1.1.92-1.5.4-.43.86-.75 1.4-.98a4.55 4.55 0 0 1 1.78-.34 4.7 4.7 0 0 1 1.8.34c.54.23 1 .55 1.4.97.38.42.68.92.9 1.5.23.6.35 1.3.35 2zm-2.2 0c0-.92-.2-1.7-.6-2.22-.38-.54-.94-.8-1.64-.8-.72 0-1.27.26-1.67.8-.4.54-.58 1.3-.58 2.22 0 .93.2 1.56.6 2.1.38.54.94.8 1.64.8s1.25-.26 1.65-.8c.4-.55.6-1.17.6-2.1zm6.97 4.7c-3.5.02-3.5-2.8-3.5-3.27L113.57.92l2.15-.34v10c0 .25 0 1.87 1.37 1.88v1.8zm3.77 0h-2.15v-9.2l2.15-.33v9.54zM119.8 3.74c.7 0 1.3-.58 1.3-1.3 0-.7-.58-1.3-1.3-1.3-.73 0-1.3.6-1.3 1.3 0 .72.58 1.3 1.3 1.3zm6.43 1c.7 0 1.3.1 1.78.27.5.18.88.42 1.17.73.28.3.5.74.6 1.18.13.46.2.95.2 1.5v5.47a25.24 25.24 0 0 1-1.5.25c-.67.1-1.42.15-2.25.15a6.83 6.83 0 0 1-1.52-.16 3.2 3.2 0 0 1-1.18-.5 2.46 2.46 0 0 1-.76-.9c-.18-.37-.27-.9-.27-1.44 0-.52.1-.85.3-1.2.2-.37.48-.67.83-.9a3.6 3.6 0 0 1 1.23-.5 7.07 7.07 0 0 1 2.2-.1l.83.16v-.35c0-.25-.03-.48-.1-.7a1.5 1.5 0 0 0-.3-.58c-.15-.18-.34-.3-.58-.4a2.54 2.54 0 0 0-.92-.17c-.5 0-.94.06-1.35.13-.4.08-.75.16-1 .25l-.27-1.74c.27-.1.67-.18 1.2-.28a9.34 9.34 0 0 1 1.65-.14zm.18 7.74c.66 0 1.15-.04 1.5-.1V10.2a5.1 5.1 0 0 0-2-.1c-.23.03-.45.1-.64.2a1.17 1.17 0 0 0-.47.38c-.13.17-.18.26-.18.52 0 .5.17.8.5.98.32.2.74.3 1.3.3zM84.1 4.8c.72 0 1.3.08 1.8.26.48.17.87.42 1.15.73.3.3.5.72.6 1.17.14.45.2.94.2 1.47v5.48a25.24 25.24 0 0 1-1.5.26c-.67.1-1.42.14-2.25.14a6.83 6.83 0 0 1-1.52-.16 3.2 3.2 0 0 1-1.18-.5 2.46 2.46 0 0 1-.76-.9c-.18-.38-.27-.9-.27-1.44 0-.53.1-.86.3-1.22.2-.36.5-.65.84-.88a3.6 3.6 0 0 1 1.24-.5 7.07 7.07 0 0 1 2.2-.1c.26.03.54.08.84.15v-.35c0-.24-.03-.48-.1-.7a1.5 1.5 0 0 0-.3-.58c-.15-.17-.34-.3-.58-.4a2.54 2.54 0 0 0-.9-.15c-.5 0-.96.05-1.37.12-.4.07-.75.15-1 .24l-.26-1.75c.27-.08.67-.17 1.18-.26a8.9 8.9 0 0 1 1.66-.15zm.2 7.73c.65 0 1.14-.04 1.48-.1v-2.17a5.1 5.1 0 0 0-1.98-.1c-.24.03-.46.1-.65.18a1.17 1.17 0 0 0-.47.4c-.12.17-.17.26-.17.52 0 .5.18.8.5.98.32.2.75.3 1.3.3zm8.68 1.74c-3.5 0-3.5-2.82-3.5-3.28L89.45.92 91.6.6v10c0 .25 0 1.87 1.38 1.88v1.8z'/%3E%3Cpath fill='%25231D3657' d='M5.03 11.03c0 .7-.26 1.24-.76 1.64-.5.4-1.2.6-2.1.6-.88 0-1.6-.14-2.17-.42v-1.2c.36.16.74.3 1.14.38.4.1.78.15 1.13.15.5 0 .88-.1 1.12-.3a.94.94 0 0 0 .35-.77.98.98 0 0 0-.33-.74c-.22-.2-.68-.44-1.37-.72-.72-.3-1.22-.62-1.52-1C.23 8.27.1 7.82.1 7.3c0-.65.22-1.17.7-1.55.46-.37 1.08-.56 1.86-.56.76 0 1.5.16 2.25.48l-.4 1.05c-.7-.3-1.32-.44-1.87-.44-.4 0-.73.08-.94.26a.9.9 0 0 0-.33.72c0 .2.04.38.12.52.08.15.22.3.42.4.2.14.55.3 1.06.52.58.24 1 .47 1.27.67.27.2.47.44.6.7.12.26.18.57.18.92zM9 13.27c-.92 0-1.64-.27-2.16-.8-.52-.55-.78-1.3-.78-2.24 0-.97.24-1.73.72-2.3.5-.54 1.15-.82 2-.82.78 0 1.4.25 1.85.72.46.48.7 1.14.7 1.97v.67H7.35c0 .58.17 1.02.46 1.33.3.3.7.47 1.24.47.36 0 .68-.04.98-.1a5.1 5.1 0 0 0 .98-.33v1.02a3.87 3.87 0 0 1-.94.32 5.72 5.72 0 0 1-1.08.1zm-.22-5.2c-.4 0-.73.12-.97.38s-.37.62-.42 1.1h2.7c0-.48-.13-.85-.36-1.1-.23-.26-.54-.38-.94-.38zm7.7 5.1l-.26-.84h-.05c-.28.36-.57.6-.86.74-.28.13-.65.2-1.1.2-.6 0-1.05-.16-1.38-.48-.32-.32-.5-.77-.5-1.34 0-.62.24-1.08.7-1.4.45-.3 1.14-.47 2.07-.5l1.02-.03V9.2c0-.37-.1-.65-.27-.84-.17-.2-.45-.28-.82-.28-.3 0-.6.04-.88.13a6.68 6.68 0 0 0-.8.33l-.4-.9a4.4 4.4 0 0 1 1.05-.4 4.86 4.86 0 0 1 1.08-.12c.76 0 1.33.18 1.7.5.4.33.6.85.6 1.56v4h-.9zm-1.9-.87c.47 0 .83-.13 1.1-.38.3-.26.43-.62.43-1.08v-.52l-.76.03c-.6.03-1.02.13-1.3.3s-.4.45-.4.82c0 .26.08.47.24.6.16.16.4.23.7.23zm7.57-5.2c.25 0 .46.03.62.06l-.12 1.18a2.38 2.38 0 0 0-.56-.06c-.5 0-.92.16-1.24.5-.3.32-.47.75-.47 1.27v3.1h-1.27V7.23h1l.16 1.05h.05c.2-.36.45-.64.77-.85a1.83 1.83 0 0 1 1.02-.3zm4.12 6.17c-.9 0-1.58-.27-2.05-.8-.47-.52-.7-1.27-.7-2.25 0-1 .24-1.77.73-2.3.5-.54 1.2-.8 2.12-.8.63 0 1.2.1 1.7.34l-.4 1c-.52-.2-.96-.3-1.3-.3-1.04 0-1.55.68-1.55 2.05 0 .67.13 1.17.38 1.5.26.34.64.5 1.13.5a3.23 3.23 0 0 0 1.6-.4v1.1a2.53 2.53 0 0 1-.73.28 4.36 4.36 0 0 1-.93.08zm8.28-.1h-1.27V9.5c0-.45-.1-.8-.28-1.02-.18-.23-.47-.34-.88-.34-.53 0-.9.16-1.16.48-.25.3-.38.85-.38 1.6v2.94h-1.26V4.8h1.26v2.12c0 .34-.02.7-.06 1.1h.08a1.76 1.76 0 0 1 .72-.67c.3-.16.66-.24 1.07-.24 1.43 0 2.15.74 2.15 2.2v3.86zM42.2 7.1c.74 0 1.32.28 1.73.82.4.53.62 1.3.62 2.26 0 .97-.2 1.73-.63 2.27-.42.54-1 .82-1.75.82s-1.33-.27-1.75-.8h-.08l-.23.7h-.94V4.8h1.26v2l-.02.64-.03.56h.05c.4-.6 1-.9 1.78-.9zm-.33 1.04c-.5 0-.88.15-1.1.45-.22.3-.34.8-.35 1.5v.08c0 .72.12 1.24.35 1.57.23.32.6.48 1.12.48.44 0 .78-.17 1-.53.24-.35.36-.87.36-1.53 0-1.35-.47-2.03-1.4-2.03zm3.24-.92h1.4l1.2 3.37c.18.47.3.92.36 1.34h.04l.18-.72 1.37-4H51l-2.53 6.73c-.46 1.23-1.23 1.85-2.3 1.85-.3 0-.56-.03-.83-.1v-1c.2.05.4.08.65.08.6 0 1.03-.36 1.28-1.06l.22-.56-2.4-5.94z'/%3E%3C/g%3E%3C/svg%3E"); 523 | background-repeat: no-repeat; 524 | background-position: center; 525 | background-size: 100%; 526 | overflow: hidden; 527 | text-indent: -9000px; 528 | width: 110px; 529 | height: 100%; 530 | display: block; 531 | margin-left: auto; 532 | margin-right: 5px; 533 | } 534 | -------------------------------------------------------------------------------- /website/src/theme/SearchBar/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import React, { useRef, useCallback } from "react"; 9 | import classnames from "classnames"; 10 | import { useHistory } from "@docusaurus/router"; 11 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 12 | import { usePluginData } from '@docusaurus/useGlobalData'; 13 | const Search = props => { 14 | const initialized = useRef(false); 15 | const searchBarRef = useRef(null); 16 | const history = useHistory(); 17 | const { siteConfig = {} } = useDocusaurusContext(); 18 | const { baseUrl } = siteConfig; 19 | const initAlgolia = (searchDocs, searchIndex, DocSearch) => { 20 | new DocSearch({ 21 | searchDocs, 22 | searchIndex, 23 | inputSelector: "#search_input_react", 24 | // Override algolia's default selection event, allowing us to do client-side 25 | // navigation and avoiding a full page refresh. 26 | handleSelected: (_input, _event, suggestion) => { 27 | const url = baseUrl + suggestion.url; 28 | // Use an anchor tag to parse the absolute url into a relative url 29 | // Alternatively, we can use new URL(suggestion.url) but its not supported in IE 30 | const a = document.createElement("a"); 31 | a.href = url; 32 | // Algolia use closest parent element id #__docusaurus when a h1 page title does not have an id 33 | // So, we can safely remove it. See https://github.com/facebook/docusaurus/issues/1828 for more details. 34 | 35 | history.push(url); 36 | } 37 | }); 38 | }; 39 | 40 | const pluginData = usePluginData('docusaurus-lunr-search'); 41 | const getSearchDoc = () => 42 | process.env.NODE_ENV === "production" 43 | ? fetch(`${baseUrl}${pluginData.fileNames.searchDoc}`).then((content) => content.json()) 44 | : Promise.resolve([]); 45 | 46 | const getLunrIndex = () => 47 | process.env.NODE_ENV === "production" 48 | ? fetch(`${baseUrl}${pluginData.fileNames.lunrIndex}`).then((content) => content.json()) 49 | : Promise.resolve([]); 50 | 51 | const loadAlgolia = () => { 52 | if (!initialized.current) { 53 | Promise.all([ 54 | getSearchDoc(), 55 | getLunrIndex(), 56 | import("./lib/DocSearch"), 57 | import("./algolia.css") 58 | ]).then(([searchDocs, searchIndex, { default: DocSearch }]) => { 59 | if( searchDocs.length === 0) { 60 | return; 61 | } 62 | initAlgolia(searchDocs, searchIndex, DocSearch); 63 | }); 64 | initialized.current = true; 65 | } 66 | }; 67 | 68 | const toggleSearchIconClick = useCallback( 69 | e => { 70 | if (!searchBarRef.current.contains(e.target)) { 71 | searchBarRef.current.focus(); 72 | } 73 | props.handleSearchBarToggle && props.handleSearchBarToggle(!props.isSearchBarExpanded); 74 | }, 75 | [props.isSearchBarExpanded] 76 | ); 77 | 78 | return ( 79 |
80 | 90 | 106 |
107 | ); 108 | }; 109 | 110 | export default Search; 111 | -------------------------------------------------------------------------------- /website/src/theme/SearchBar/lib/DocSearch.js: -------------------------------------------------------------------------------- 1 | import Hogan from "hogan.js"; 2 | import LunrSearchAdapter from "./lunar-search"; 3 | import autocomplete from "autocomplete.js"; 4 | import templates from "./templates"; 5 | import utils from "./utils"; 6 | import $ from "autocomplete.js/zepto"; 7 | 8 | /** 9 | * Adds an autocomplete dropdown to an input field 10 | * @function DocSearch 11 | * @param {Object} options.searchDocs Search Documents 12 | * @param {Object} options.searchIndex Lune searchIndexes 13 | * @param {string} options.inputSelector CSS selector that targets the input 14 | * value. 15 | * @param {Object} [options.autocompleteOptions] Options to pass to the underlying autocomplete instance 16 | * @return {Object} 17 | */ 18 | class DocSearch { 19 | constructor({ 20 | searchDocs, 21 | searchIndex, 22 | inputSelector, 23 | debug = false, 24 | queryDataCallback = null, 25 | autocompleteOptions = { 26 | debug: false, 27 | hint: false, 28 | autoselect: true 29 | }, 30 | transformData = false, 31 | queryHook = false, 32 | handleSelected = false, 33 | enhancedSearchInput = false, 34 | layout = "collumns" 35 | }) { 36 | this.input = DocSearch.getInputFromSelector(inputSelector); 37 | this.queryDataCallback = queryDataCallback || null; 38 | const autocompleteOptionsDebug = 39 | autocompleteOptions && autocompleteOptions.debug 40 | ? autocompleteOptions.debug 41 | : false; 42 | // eslint-disable-next-line no-param-reassign 43 | autocompleteOptions.debug = debug || autocompleteOptionsDebug; 44 | this.autocompleteOptions = autocompleteOptions; 45 | this.autocompleteOptions.cssClasses = 46 | this.autocompleteOptions.cssClasses || {}; 47 | this.autocompleteOptions.cssClasses.prefix = 48 | this.autocompleteOptions.cssClasses.prefix || "ds"; 49 | const inputAriaLabel = 50 | this.input && 51 | typeof this.input.attr === "function" && 52 | this.input.attr("aria-label"); 53 | this.autocompleteOptions.ariaLabel = 54 | this.autocompleteOptions.ariaLabel || inputAriaLabel || "search input"; 55 | 56 | this.isSimpleLayout = layout === "simple"; 57 | 58 | this.client = new LunrSearchAdapter(searchDocs, searchIndex); 59 | 60 | if (enhancedSearchInput) { 61 | this.input = DocSearch.injectSearchBox(this.input); 62 | } 63 | this.autocomplete = autocomplete(this.input, autocompleteOptions, [ 64 | { 65 | source: this.getAutocompleteSource(transformData, queryHook), 66 | templates: { 67 | suggestion: DocSearch.getSuggestionTemplate(this.isSimpleLayout), 68 | footer: templates.footer, 69 | empty: DocSearch.getEmptyTemplate() 70 | } 71 | } 72 | ]); 73 | 74 | const customHandleSelected = handleSelected; 75 | this.handleSelected = customHandleSelected || this.handleSelected; 76 | 77 | // We prevent default link clicking if a custom handleSelected is defined 78 | if (customHandleSelected) { 79 | $(".algolia-autocomplete").on("click", ".ds-suggestions a", event => { 80 | event.preventDefault(); 81 | }); 82 | } 83 | 84 | this.autocomplete.on( 85 | "autocomplete:selected", 86 | this.handleSelected.bind(null, this.autocomplete.autocomplete) 87 | ); 88 | 89 | this.autocomplete.on( 90 | "autocomplete:shown", 91 | this.handleShown.bind(null, this.input) 92 | ); 93 | 94 | if (enhancedSearchInput) { 95 | DocSearch.bindSearchBoxEvent(); 96 | } 97 | } 98 | 99 | static injectSearchBox(input) { 100 | input.before(templates.searchBox); 101 | const newInput = input 102 | .prev() 103 | .prev() 104 | .find("input"); 105 | input.remove(); 106 | return newInput; 107 | } 108 | 109 | static bindSearchBoxEvent() { 110 | $('.searchbox [type="reset"]').on("click", function () { 111 | $("input#docsearch").focus(); 112 | $(this).addClass("hide"); 113 | autocomplete.autocomplete.setVal(""); 114 | }); 115 | 116 | $("input#docsearch").on("keyup", () => { 117 | const searchbox = document.querySelector("input#docsearch"); 118 | const reset = document.querySelector('.searchbox [type="reset"]'); 119 | reset.className = "searchbox__reset"; 120 | if (searchbox.value.length === 0) { 121 | reset.className += " hide"; 122 | } 123 | }); 124 | } 125 | 126 | /** 127 | * Returns the matching input from a CSS selector, null if none matches 128 | * @function getInputFromSelector 129 | * @param {string} selector CSS selector that matches the search 130 | * input of the page 131 | * @returns {void} 132 | */ 133 | static getInputFromSelector(selector) { 134 | const input = $(selector).filter("input"); 135 | return input.length ? $(input[0]) : null; 136 | } 137 | 138 | /** 139 | * Returns the `source` method to be passed to autocomplete.js. It will query 140 | * the Algolia index and call the callbacks with the formatted hits. 141 | * @function getAutocompleteSource 142 | * @param {function} transformData An optional function to transform the hits 143 | * @param {function} queryHook An optional function to transform the query 144 | * @returns {function} Method to be passed as the `source` option of 145 | * autocomplete 146 | */ 147 | getAutocompleteSource(transformData, queryHook) { 148 | return (query, callback) => { 149 | if (queryHook) { 150 | // eslint-disable-next-line no-param-reassign 151 | query = queryHook(query) || query; 152 | } 153 | this.client.search(query).then(hits => { 154 | if ( 155 | this.queryDataCallback && 156 | typeof this.queryDataCallback == "function" 157 | ) { 158 | this.queryDataCallback(hits); 159 | } 160 | if (transformData) { 161 | hits = transformData(hits) || hits; 162 | } 163 | callback(DocSearch.formatHits(hits)); 164 | }); 165 | }; 166 | } 167 | 168 | // Given a list of hits returned by the API, will reformat them to be used in 169 | // a Hogan template 170 | static formatHits(receivedHits) { 171 | const clonedHits = utils.deepClone(receivedHits); 172 | const hits = clonedHits.map(hit => { 173 | if (hit._highlightResult) { 174 | // eslint-disable-next-line no-param-reassign 175 | hit._highlightResult = utils.mergeKeyWithParent( 176 | hit._highlightResult, 177 | "hierarchy" 178 | ); 179 | } 180 | return utils.mergeKeyWithParent(hit, "hierarchy"); 181 | }); 182 | 183 | // Group hits by category / subcategory 184 | let groupedHits = utils.groupBy(hits, "lvl0"); 185 | $.each(groupedHits, (level, collection) => { 186 | const groupedHitsByLvl1 = utils.groupBy(collection, "lvl1"); 187 | const flattenedHits = utils.flattenAndFlagFirst( 188 | groupedHitsByLvl1, 189 | "isSubCategoryHeader" 190 | ); 191 | groupedHits[level] = flattenedHits; 192 | }); 193 | groupedHits = utils.flattenAndFlagFirst(groupedHits, "isCategoryHeader"); 194 | 195 | // Translate hits into smaller objects to be send to the template 196 | return groupedHits.map(hit => { 197 | const url = DocSearch.formatURL(hit); 198 | const category = utils.getHighlightedValue(hit, "lvl0"); 199 | const subcategory = utils.getHighlightedValue(hit, "lvl1") || category; 200 | const displayTitle = utils 201 | .compact([ 202 | utils.getHighlightedValue(hit, "lvl2") || subcategory, 203 | utils.getHighlightedValue(hit, "lvl3"), 204 | utils.getHighlightedValue(hit, "lvl4"), 205 | utils.getHighlightedValue(hit, "lvl5"), 206 | utils.getHighlightedValue(hit, "lvl6") 207 | ]) 208 | .join( 209 | '' 210 | ); 211 | const text = utils.getSnippetedValue(hit, "content"); 212 | const isTextOrSubcategoryNonEmpty = 213 | (subcategory && subcategory !== "") || 214 | (displayTitle && displayTitle !== ""); 215 | const isLvl1EmptyOrDuplicate = 216 | !subcategory || subcategory === "" || subcategory === category; 217 | const isLvl2 = 218 | displayTitle && displayTitle !== "" && displayTitle !== subcategory; 219 | const isLvl1 = 220 | !isLvl2 && 221 | (subcategory && subcategory !== "" && subcategory !== category); 222 | const isLvl0 = !isLvl1 && !isLvl2; 223 | 224 | return { 225 | isLvl0, 226 | isLvl1, 227 | isLvl2, 228 | isLvl1EmptyOrDuplicate, 229 | isCategoryHeader: hit.isCategoryHeader, 230 | isSubCategoryHeader: hit.isSubCategoryHeader, 231 | isTextOrSubcategoryNonEmpty, 232 | category, 233 | subcategory, 234 | title: displayTitle, 235 | text, 236 | url 237 | }; 238 | }); 239 | } 240 | 241 | static formatURL(hit) { 242 | const { url, anchor } = hit; 243 | if (url) { 244 | const containsAnchor = url.indexOf("#") !== -1; 245 | if (containsAnchor) return url; 246 | else if (anchor) return `${hit.url}#${hit.anchor}`; 247 | return url; 248 | } else if (anchor) return `#${hit.anchor}`; 249 | /* eslint-disable */ 250 | console.warn("no anchor nor url for : ", JSON.stringify(hit)); 251 | /* eslint-enable */ 252 | return null; 253 | } 254 | 255 | static getEmptyTemplate() { 256 | return args => Hogan.compile(templates.empty).render(args); 257 | } 258 | 259 | static getSuggestionTemplate(isSimpleLayout) { 260 | const stringTemplate = isSimpleLayout 261 | ? templates.suggestionSimple 262 | : templates.suggestion; 263 | const template = Hogan.compile(stringTemplate); 264 | return suggestion => template.render(suggestion); 265 | } 266 | 267 | handleSelected(input, event, suggestion, datasetNumber, context = {}) { 268 | // Do nothing if click on the suggestion, as it's already a
, the 269 | // browser will take care of it. This allow Ctrl-Clicking on results and not 270 | // having the main window being redirected as well 271 | if (context.selectionMethod === "click") { 272 | return; 273 | } 274 | 275 | input.setVal(""); 276 | window.location.assign(suggestion.url); 277 | } 278 | 279 | handleShown(input) { 280 | const middleOfInput = input.offset().left + input.width() / 2; 281 | let middleOfWindow = $(document).width() / 2; 282 | 283 | if (isNaN(middleOfWindow)) { 284 | middleOfWindow = 900; 285 | } 286 | 287 | const alignClass = 288 | middleOfInput - middleOfWindow >= 0 289 | ? "algolia-autocomplete-right" 290 | : "algolia-autocomplete-left"; 291 | const otherAlignClass = 292 | middleOfInput - middleOfWindow < 0 293 | ? "algolia-autocomplete-right" 294 | : "algolia-autocomplete-left"; 295 | const autocompleteWrapper = $(".algolia-autocomplete"); 296 | if (!autocompleteWrapper.hasClass(alignClass)) { 297 | autocompleteWrapper.addClass(alignClass); 298 | } 299 | 300 | if (autocompleteWrapper.hasClass(otherAlignClass)) { 301 | autocompleteWrapper.removeClass(otherAlignClass); 302 | } 303 | } 304 | } 305 | 306 | export default DocSearch; -------------------------------------------------------------------------------- /website/src/theme/SearchBar/lib/lunar-search.js: -------------------------------------------------------------------------------- 1 | import lunr from "@generated/lunr.client"; 2 | lunr.tokenizer.separator = /[\s\-/]+/; 3 | 4 | class LunrSearchAdapter { 5 | constructor(searchDocs, searchIndex) { 6 | this.searchDocs = searchDocs; 7 | this.lunrIndex = lunr.Index.load(searchIndex); 8 | } 9 | 10 | getLunrResult(input) { 11 | return this.lunrIndex.query(function (query) { 12 | const tokens = lunr.tokenizer(input); 13 | query.term(tokens, { 14 | boost: 10 15 | }); 16 | query.term(tokens, { 17 | wildcard: lunr.Query.wildcard.TRAILING 18 | }); 19 | }); 20 | } 21 | 22 | getHit(doc, formattedTitle, formattedContent) { 23 | return { 24 | hierarchy: { 25 | lvl0: doc.pageTitle || doc.title, 26 | lvl1: doc.type === 0 ? null : doc.title 27 | }, 28 | url: doc.url, 29 | _snippetResult: formattedContent ? { 30 | content: { 31 | value: formattedContent, 32 | matchLevel: "full" 33 | } 34 | } : null, 35 | _highlightResult: { 36 | hierarchy: { 37 | lvl0: { 38 | value: doc.type === 0 ? formattedTitle || doc.title : doc.pageTitle, 39 | }, 40 | lvl1: 41 | doc.type === 0 42 | ? null 43 | : { 44 | value: formattedTitle || doc.title 45 | } 46 | } 47 | } 48 | }; 49 | } 50 | getTitleHit(doc, position, length) { 51 | const start = position[0]; 52 | const end = position[0] + length; 53 | let formattedTitle = doc.title.substring(0, start) + '' + doc.title.substring(start, end) + '' + doc.title.substring(end, doc.title.length); 54 | return this.getHit(doc, formattedTitle) 55 | } 56 | 57 | getKeywordHit(doc, position, length) { 58 | const start = position[0]; 59 | const end = position[0] + length; 60 | let formattedTitle = doc.title + '
Keywords: ' + doc.keywords.substring(0, start) + '' + doc.keywords.substring(start, end) + '' + doc.keywords.substring(end, doc.keywords.length) + '' 61 | return this.getHit(doc, formattedTitle) 62 | } 63 | 64 | getContentHit(doc, position) { 65 | const start = position[0]; 66 | const end = position[0] + position[1]; 67 | let previewStart = start; 68 | let previewEnd = end; 69 | let ellipsesBefore = true; 70 | let ellipsesAfter = true; 71 | for (let k = 0; k < 3; k++) { 72 | const nextSpace = doc.content.lastIndexOf(' ', previewStart - 2); 73 | const nextDot = doc.content.lastIndexOf('.', previewStart - 2); 74 | if ((nextDot > 0) && (nextDot > nextSpace)) { 75 | previewStart = nextDot + 1; 76 | ellipsesBefore = false; 77 | break; 78 | } 79 | if (nextSpace < 0) { 80 | previewStart = 0; 81 | ellipsesBefore = false; 82 | break; 83 | } 84 | previewStart = nextSpace + 1; 85 | } 86 | for (let k = 0; k < 10; k++) { 87 | const nextSpace = doc.content.indexOf(' ', previewEnd + 1); 88 | const nextDot = doc.content.indexOf('.', previewEnd + 1); 89 | if ((nextDot > 0) && (nextDot < nextSpace)) { 90 | previewEnd = nextDot; 91 | ellipsesAfter = false; 92 | break; 93 | } 94 | if (nextSpace < 0) { 95 | previewEnd = doc.content.length; 96 | ellipsesAfter = false; 97 | break; 98 | } 99 | previewEnd = nextSpace; 100 | } 101 | let preview = doc.content.substring(previewStart, start); 102 | if (ellipsesBefore) { 103 | preview = '... ' + preview; 104 | } 105 | preview += '' + doc.content.substring(start, end) + ''; 106 | preview += doc.content.substring(end, previewEnd); 107 | if (ellipsesAfter) { 108 | preview += ' ...'; 109 | } 110 | return this.getHit(doc, null, preview); 111 | 112 | } 113 | search(input) { 114 | return new Promise((resolve, rej) => { 115 | const results = this.getLunrResult(input); 116 | const hits = []; 117 | results.length > 5 && (results.length = 5); 118 | this.titleHitsRes = [] 119 | this.contentHitsRes = [] 120 | results.forEach(result => { 121 | const doc = this.searchDocs[result.ref]; 122 | const { metadata } = result.matchData; 123 | for (let i in metadata) { 124 | if (metadata[i].title) { 125 | if (!this.titleHitsRes.includes(result.ref)) { 126 | const position = metadata[i].title.position[0] 127 | hits.push(this.getTitleHit(doc, position, input.length)); 128 | this.titleHitsRes.push(result.ref); 129 | } 130 | } else if (metadata[i].content) { 131 | const position = metadata[i].content.position[0] 132 | hits.push(this.getContentHit(doc, position)) 133 | } else if (metadata[i].keywords) { 134 | const position = metadata[i].keywords.position[0] 135 | hits.push(this.getKeywordHit(doc, position, input.length)); 136 | this.titleHitsRes.push(result.ref); 137 | } 138 | } 139 | }); 140 | hits.length > 5 && (hits.length = 5); 141 | resolve(hits); 142 | }); 143 | } 144 | } 145 | 146 | export default LunrSearchAdapter; 147 | -------------------------------------------------------------------------------- /website/src/theme/SearchBar/lib/templates.js: -------------------------------------------------------------------------------- 1 | const prefix = 'algolia-docsearch'; 2 | const suggestionPrefix = `${prefix}-suggestion`; 3 | const footerPrefix = `${prefix}-footer`; 4 | 5 | /* eslint-disable max-len */ 6 | 7 | const templates = { 8 | suggestion: ` 9 |
16 |
17 | {{{category}}} 18 |
19 |
20 |
21 | {{{subcategory}}} 22 |
23 | {{#isTextOrSubcategoryNonEmpty}} 24 |
25 |
{{{subcategory}}}
26 |
{{{title}}}
27 | {{#text}}
{{{text}}}
{{/text}} 28 |
29 | {{/isTextOrSubcategoryNonEmpty}} 30 |
31 |
32 | `, 33 | suggestionSimple: ` 34 |
39 |
40 | {{^isLvl0}} 41 | {{{category}}} 42 | {{^isLvl1}} 43 | {{^isLvl1EmptyOrDuplicate}} 44 | 45 | {{{subcategory}}} 46 | 47 | {{/isLvl1EmptyOrDuplicate}} 48 | {{/isLvl1}} 49 | {{/isLvl0}} 50 |
51 | {{#isLvl2}} 52 | {{{title}}} 53 | {{/isLvl2}} 54 | {{#isLvl1}} 55 | {{{subcategory}}} 56 | {{/isLvl1}} 57 | {{#isLvl0}} 58 | {{{category}}} 59 | {{/isLvl0}} 60 |
61 |
62 |
63 | {{#text}} 64 |
65 |
{{{text}}}
66 |
67 | {{/text}} 68 |
69 |
70 | `, 71 | footer: ` 72 |
73 |
74 | `, 75 | empty: ` 76 |
77 |
78 |
79 |
80 |
81 | No results found for query "{{query}}" 82 |
83 |
84 |
85 |
86 |
87 | `, 88 | searchBox: ` 89 | 104 | 105 | 111 | `, 112 | }; 113 | 114 | export default templates; 115 | -------------------------------------------------------------------------------- /website/src/theme/SearchBar/lib/utils.js: -------------------------------------------------------------------------------- 1 | import $ from "autocomplete.js/zepto"; 2 | 3 | const utils = { 4 | /* 5 | * Move the content of an object key one level higher. 6 | * eg. 7 | * { 8 | * name: 'My name', 9 | * hierarchy: { 10 | * lvl0: 'Foo', 11 | * lvl1: 'Bar' 12 | * } 13 | * } 14 | * Will be converted to 15 | * { 16 | * name: 'My name', 17 | * lvl0: 'Foo', 18 | * lvl1: 'Bar' 19 | * } 20 | * @param {Object} object Main object 21 | * @param {String} property Main object key to move up 22 | * @return {Object} 23 | * @throws Error when key is not an attribute of Object or is not an object itself 24 | */ 25 | mergeKeyWithParent(object, property) { 26 | if (object[property] === undefined) { 27 | return object; 28 | } 29 | if (typeof object[property] !== 'object') { 30 | return object; 31 | } 32 | const newObject = $.extend({}, object, object[property]); 33 | delete newObject[property]; 34 | return newObject; 35 | }, 36 | /* 37 | * Group all objects of a collection by the value of the specified attribute 38 | * If the attribute is a string, use the lowercase form. 39 | * 40 | * eg. 41 | * groupBy([ 42 | * {name: 'Tim', category: 'dev'}, 43 | * {name: 'Vincent', category: 'dev'}, 44 | * {name: 'Ben', category: 'sales'}, 45 | * {name: 'Jeremy', category: 'sales'}, 46 | * {name: 'AlexS', category: 'dev'}, 47 | * {name: 'AlexK', category: 'sales'} 48 | * ], 'category'); 49 | * => 50 | * { 51 | * 'devs': [ 52 | * {name: 'Tim', category: 'dev'}, 53 | * {name: 'Vincent', category: 'dev'}, 54 | * {name: 'AlexS', category: 'dev'} 55 | * ], 56 | * 'sales': [ 57 | * {name: 'Ben', category: 'sales'}, 58 | * {name: 'Jeremy', category: 'sales'}, 59 | * {name: 'AlexK', category: 'sales'} 60 | * ] 61 | * } 62 | * @param {array} collection Array of objects to group 63 | * @param {String} property The attribute on which apply the grouping 64 | * @return {array} 65 | * @throws Error when one of the element does not have the specified property 66 | */ 67 | groupBy(collection, property) { 68 | const newCollection = {}; 69 | $.each(collection, (index, item) => { 70 | if (item[property] === undefined) { 71 | throw new Error(`[groupBy]: Object has no key ${property}`); 72 | } 73 | let key = item[property]; 74 | if (typeof key === 'string') { 75 | key = key.toLowerCase(); 76 | } 77 | // fix #171 the given data type of docsearch hits might be conflict with the properties of the native Object, 78 | // such as the constructor, so we need to do this check. 79 | if (!Object.prototype.hasOwnProperty.call(newCollection, key)) { 80 | newCollection[key] = []; 81 | } 82 | newCollection[key].push(item); 83 | }); 84 | return newCollection; 85 | }, 86 | /* 87 | * Return an array of all the values of the specified object 88 | * eg. 89 | * values({ 90 | * foo: 42, 91 | * bar: true, 92 | * baz: 'yep' 93 | * }) 94 | * => 95 | * [42, true, yep] 96 | * @param {object} object Object to extract values from 97 | * @return {array} 98 | */ 99 | values(object) { 100 | return Object.keys(object).map(key => object[key]); 101 | }, 102 | /* 103 | * Flattens an array 104 | * eg. 105 | * flatten([1, 2, [3, 4], [5, 6]]) 106 | * => 107 | * [1, 2, 3, 4, 5, 6] 108 | * @param {array} array Array to flatten 109 | * @return {array} 110 | */ 111 | flatten(array) { 112 | const results = []; 113 | array.forEach(value => { 114 | if (!Array.isArray(value)) { 115 | results.push(value); 116 | return; 117 | } 118 | value.forEach(subvalue => { 119 | results.push(subvalue); 120 | }); 121 | }); 122 | return results; 123 | }, 124 | /* 125 | * Flatten all values of an object into an array, marking each first element of 126 | * each group with a specific flag 127 | * eg. 128 | * flattenAndFlagFirst({ 129 | * 'devs': [ 130 | * {name: 'Tim', category: 'dev'}, 131 | * {name: 'Vincent', category: 'dev'}, 132 | * {name: 'AlexS', category: 'dev'} 133 | * ], 134 | * 'sales': [ 135 | * {name: 'Ben', category: 'sales'}, 136 | * {name: 'Jeremy', category: 'sales'}, 137 | * {name: 'AlexK', category: 'sales'} 138 | * ] 139 | * , 'isTop'); 140 | * => 141 | * [ 142 | * {name: 'Tim', category: 'dev', isTop: true}, 143 | * {name: 'Vincent', category: 'dev', isTop: false}, 144 | * {name: 'AlexS', category: 'dev', isTop: false}, 145 | * {name: 'Ben', category: 'sales', isTop: true}, 146 | * {name: 'Jeremy', category: 'sales', isTop: false}, 147 | * {name: 'AlexK', category: 'sales', isTop: false} 148 | * ] 149 | * @param {object} object Object to flatten 150 | * @param {string} flag Flag to set to true on first element of each group 151 | * @return {array} 152 | */ 153 | flattenAndFlagFirst(object, flag) { 154 | const values = this.values(object).map(collection => 155 | collection.map((item, index) => { 156 | // eslint-disable-next-line no-param-reassign 157 | item[flag] = index === 0; 158 | return item; 159 | }) 160 | ); 161 | return this.flatten(values); 162 | }, 163 | /* 164 | * Removes all empty strings, null, false and undefined elements array 165 | * eg. 166 | * compact([42, false, null, undefined, '', [], 'foo']); 167 | * => 168 | * [42, [], 'foo'] 169 | * @param {array} array Array to compact 170 | * @return {array} 171 | */ 172 | compact(array) { 173 | const results = []; 174 | array.forEach(value => { 175 | if (!value) { 176 | return; 177 | } 178 | results.push(value); 179 | }); 180 | return results; 181 | }, 182 | /* 183 | * Returns the highlighted value of the specified key in the specified object. 184 | * If no highlighted value is available, will return the key value directly 185 | * eg. 186 | * getHighlightedValue({ 187 | * _highlightResult: { 188 | * text: { 189 | * value: 'foo' 190 | * } 191 | * }, 192 | * text: 'foo' 193 | * }, 'text'); 194 | * => 195 | * 'foo' 196 | * @param {object} object Hit object returned by the Algolia API 197 | * @param {string} property Object key to look for 198 | * @return {string} 199 | **/ 200 | getHighlightedValue(object, property) { 201 | if ( 202 | object._highlightResult && 203 | object._highlightResult.hierarchy_camel && 204 | object._highlightResult.hierarchy_camel[property] && 205 | object._highlightResult.hierarchy_camel[property].matchLevel && 206 | object._highlightResult.hierarchy_camel[property].matchLevel !== 'none' && 207 | object._highlightResult.hierarchy_camel[property].value 208 | ) { 209 | return object._highlightResult.hierarchy_camel[property].value; 210 | } 211 | if ( 212 | object._highlightResult && 213 | object._highlightResult && 214 | object._highlightResult[property] && 215 | object._highlightResult[property].value 216 | ) { 217 | return object._highlightResult[property].value; 218 | } 219 | return object[property]; 220 | }, 221 | /* 222 | * Returns the snippeted value of the specified key in the specified object. 223 | * If no highlighted value is available, will return the key value directly. 224 | * Will add starting and ending ellipsis (…) if we detect that a sentence is 225 | * incomplete 226 | * eg. 227 | * getSnippetedValue({ 228 | * _snippetResult: { 229 | * text: { 230 | * value: 'This is an unfinished sentence' 231 | * } 232 | * }, 233 | * text: 'This is an unfinished sentence' 234 | * }, 'text'); 235 | * => 236 | * 'This is an unfinished sentence…' 237 | * @param {object} object Hit object returned by the Algolia API 238 | * @param {string} property Object key to look for 239 | * @return {string} 240 | **/ 241 | getSnippetedValue(object, property) { 242 | if ( 243 | !object._snippetResult || 244 | !object._snippetResult[property] || 245 | !object._snippetResult[property].value 246 | ) { 247 | return object[property]; 248 | } 249 | let snippet = object._snippetResult[property].value; 250 | 251 | if (snippet[0] !== snippet[0].toUpperCase()) { 252 | snippet = `…${snippet}`; 253 | } 254 | if (['.', '!', '?'].indexOf(snippet[snippet.length - 1]) === -1) { 255 | snippet = `${snippet}…`; 256 | } 257 | return snippet; 258 | }, 259 | /* 260 | * Deep clone an object. 261 | * Note: This will not clone functions and dates 262 | * @param {object} object Object to clone 263 | * @return {object} 264 | */ 265 | deepClone(object) { 266 | return JSON.parse(JSON.stringify(object)); 267 | }, 268 | }; 269 | 270 | export default utils; 271 | -------------------------------------------------------------------------------- /website/src/theme/SearchBar/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | .search-icon { 9 | background-image: var(--ifm-navbar-search-input-icon); 10 | height: auto; 11 | width: 24px; 12 | cursor: pointer; 13 | padding: 8px; 14 | line-height: 32px; 15 | background-repeat: no-repeat; 16 | background-position: center; 17 | display: none; 18 | } 19 | 20 | .search-icon-hidden { 21 | visibility: hidden; 22 | } 23 | 24 | @media (max-width: 360px) { 25 | .search-bar { 26 | width: 0 !important; 27 | background: none !important; 28 | padding: 0 !important; 29 | transition: none !important; 30 | } 31 | 32 | .search-bar-expanded { 33 | width: 9rem !important; 34 | } 35 | 36 | .search-icon { 37 | display: inline; 38 | vertical-align: sub; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cassiozen/useStateMachine/9882a802d2091137e8fddccde9095a75e7568459/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/battery.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cassiozen/useStateMachine/9882a802d2091137e8fddccde9095a75e7568459/website/static/img/logo.png -------------------------------------------------------------------------------- /website/static/img/react.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/static/img/typescript.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------