├── .npmignore ├── .vscode └── settings.json ├── .eslintrc ├── .github └── workflows │ ├── test.yaml │ └── release.yaml ├── test ├── keys.test.ts ├── debug.test.ts ├── variousValues.test.ts ├── array.test.ts ├── enums.test.ts ├── fns.test.ts ├── ai.test.ts ├── types.test-d.ts └── index.test.ts ├── src ├── debug.ts ├── types.ts └── index.ts ├── tsconfig.json ├── tsup.config.ts ├── LICENSE ├── package.json ├── .gitignore └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | *.test.ts 2 | .vscode/ 3 | .eslintrc 4 | *.lockb 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@switz/eslint-config/typescript.cjs" 4 | ], 5 | "rules": { 6 | "@typescript-eslint/no-namespace": 0 7 | } 8 | } -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | test-and-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Bun 14 | uses: oven-sh/setup-bun@v1 15 | - uses: pnpm/action-setup@v2 16 | with: 17 | version: 8 18 | - run: pnpm i 19 | - run: bun run test 20 | - run: bun run build 21 | 22 | -------------------------------------------------------------------------------- /test/keys.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic key error test', () => { 6 | const demoNotRecorded = false; 7 | 8 | expect(() => 9 | driver({ 10 | states: { 11 | 'isNotRecorded': demoNotRecorded, 12 | '0': true, 13 | 'isUploaded': false, 14 | }, 15 | derived: { 16 | isDisabled: ['isNotRecorded', 'isUploading'], 17 | text: { 18 | isNotRecorded: 'Demo Disabled', 19 | 0: 'Demo Uploading...', 20 | isUploaded: 'Download Demo', 21 | }, 22 | }, 23 | }) 24 | ).toThrow(); 25 | }); 26 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import driver from './index.js'; 2 | import type { Config, DerivedConfig, Return } from './types'; 3 | 4 | function debugDriver>( 5 | config: Config 6 | ): Return[] { 7 | const states = Object.keys(config.states); 8 | 9 | // injects a __debug_noMatches__ state key to show what happens when all state keys are false 10 | return states.concat('__debug_noMatches__').map((stateKey) => { 11 | const internalStates = states.map((internalStateKey) => [ 12 | internalStateKey, 13 | internalStateKey === stateKey, 14 | ]); 15 | 16 | return driver({ 17 | ...config, 18 | states: Object.fromEntries(internalStates), 19 | }); 20 | }); 21 | } 22 | 23 | export default debugDriver; 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [ main ] 5 | 6 | jobs: 7 | test-and-release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Setup Bun 12 | uses: oven-sh/setup-bun@v1 13 | - uses: pnpm/action-setup@v2 14 | with: 15 | version: 8 16 | - run: pnpm i 17 | - run: bun run test 18 | - run: bun run build 19 | - uses: JS-DevTools/npm-publish@v2 20 | with: 21 | token: ${{ secrets.NPM_AUTH_TOKEN }} 22 | - name: get-npm-version 23 | id: package-version 24 | uses: martinbeentjes/npm-get-version-action@v1.3.1 25 | - uses: ncipollo/release-action@v1 26 | with: 27 | tag: ${{ steps.package-version.outputs.current-version}} 28 | allowUpdates: true 29 | removeArtifacts: true 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "importHelpers": false, 7 | "declaration": true, 8 | "sourceMap": false, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "downlevelIteration": true, 16 | "skipLibCheck": true, 17 | "jsx": "preserve", 18 | "allowSyntheticDefaultImports": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowJs": true, 21 | "esModuleInterop": true, 22 | "types": [ 23 | "bun-types" // add Bun global 24 | ], 25 | "emitDeclarationOnly": true, 26 | "outDir": "dist", 27 | "declarationMap": true 28 | }, 29 | "include": ["src/index.ts", "src/debug.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /test/debug.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import debugDriver from '../src/debug.js'; 4 | 5 | test('basic test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = debugDriver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: (states) => states.isNotRecorded || states.isUploading, 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(String(demoButton.map((all) => all.activeState))).toBe( 25 | String(['isNotRecorded', 'isUploading', 'isUploaded', undefined]) 26 | ); 27 | expect(String(demoButton.map((all) => all.text))).toBe( 28 | String(['Demo Disabled', 'Demo Uploading...', 'Download Demo', undefined]) 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | const commonOptions = { 4 | entry: ['src/index.ts', 'src/debug.ts'], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | minify: true, 9 | }; 10 | 11 | export default defineConfig(() => [ 12 | { 13 | ...commonOptions, 14 | format: ['esm'], 15 | outExtension: () => ({ js: '.mjs' }), 16 | dts: true, 17 | clean: true, 18 | }, 19 | { 20 | ...commonOptions, 21 | format: 'cjs', 22 | outDir: './dist/cjs/', 23 | outExtension: () => ({ js: '.cjs' }), 24 | esbuildOptions: (options) => { 25 | options.footer = { 26 | // This will ensure we can continue writing this plugin 27 | // as a modern ECMA module, while still publishing this as a CommonJS 28 | // library with a default export, as that's how ESLint expects plugins to look. 29 | // @see https://github.com/evanw/esbuild/issues/1182#issuecomment-1011414271 30 | js: 'module.exports = module.exports.default;', 31 | }; 32 | }, 33 | }, 34 | ]); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Daniel Saewitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/variousValues.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic enum test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = driver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: (_, stateEnums, activeEnum) => (activeEnum ?? 0) <= stateEnums.isUploading, 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(demoButton.activeState).toBe('isUploading'); 25 | expect(demoButton.isDisabled).toBe(true); 26 | expect(demoButton.text).toBe('Demo Uploading...'); 27 | }); 28 | 29 | test('basic enum test2', () => { 30 | const demoNotRecorded = false; 31 | 32 | const demoButton = driver({ 33 | states: { 34 | isNotRecorded: demoNotRecorded, 35 | isUploading: false, 36 | isUploaded: true, 37 | }, 38 | derived: { 39 | isDisabled: (_, stateEnums, activeEnum) => (activeEnum ?? 0) <= stateEnums.isUploading, 40 | text: { 41 | isNotRecorded: 'Demo Disabled', 42 | isUploading: 'Demo Uploading...', 43 | isUploaded: 'Download Demo', 44 | }, 45 | }, 46 | }); 47 | 48 | expect(demoButton.activeState).toBe('isUploaded'); 49 | expect(demoButton.isDisabled).toBe(false); 50 | expect(demoButton.text).toBe('Download Demo'); 51 | }); 52 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type ProtectedKeys = keyof MetadataReturn; 2 | 3 | type ProtectedObject = T & { 4 | [P in ProtectedKeys]?: never; 5 | }; 6 | 7 | type RequireAtLeastOne = Pick> & 8 | { 9 | [K in Keys]-?: Required> & Partial>>; 10 | }[Keys]; 11 | 12 | type DerivedFn = ( 13 | states: Record, 14 | enums: Record, 15 | activeEnum: number | undefined 16 | ) => unknown; 17 | 18 | export type Config> = { 19 | states: Record; 20 | /** 21 | * @deprecated the `flags` field has been renamed to derived, please transition your code 22 | */ 23 | flags?: ProtectedObject; 24 | derived?: ProtectedObject; 25 | }; 26 | 27 | export type DerivedConfig = Record< 28 | string, 29 | RequireAtLeastOne>, T> | DerivedFn | T[] 30 | >; 31 | 32 | export type Return> = DerivedReturn & 33 | MetadataReturn; 34 | 35 | type MetadataReturn = { 36 | activeState: T | undefined; 37 | activeEnum: number | undefined; 38 | enums: Record; 39 | states: Record; 40 | }; 41 | 42 | type DerivedReturn> = { 43 | [P in keyof K]: K[P] extends DerivedFn 44 | ? ReturnType 45 | : K[P] extends Array 46 | ? boolean 47 | : K[P] extends object 48 | ? K[P][keyof K[P]] | undefined 49 | : undefined; 50 | }; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@switz/driver", 3 | "main": "dist/cjs/index.cjs", 4 | "module": "dist/index.mjs", 5 | "types": "dist/index.d.mts", 6 | "files": [ 7 | "dist", 8 | "src", 9 | "README.md" 10 | ], 11 | "exports": { 12 | "./package.json": "./package.json", 13 | ".": { 14 | "import": { 15 | "types": "./dist/index.d.mts", 16 | "default": "./dist/index.mjs" 17 | }, 18 | "require": { 19 | "types": "./dist/cjs/index.d.cts", 20 | "default": "./dist/cjs/index.cjs" 21 | } 22 | }, 23 | "./debug": { 24 | "import": { 25 | "types": "./dist/debug.d.mts", 26 | "default": "./dist/debug.mjs" 27 | }, 28 | "require": { 29 | "types": "./dist/cjs/debug.d.cts", 30 | "default": "./dist/cjs/debug.cjs" 31 | } 32 | } 33 | }, 34 | "sideEffects": false, 35 | "private": false, 36 | "author": { 37 | "name": "Daniel Saewitz (switz)", 38 | "url": "https://saewitz.com" 39 | }, 40 | "version": "0.10.0", 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/switz/driver.git" 44 | }, 45 | "license": "MIT", 46 | "scripts": { 47 | "prepublish": "build", 48 | "test": "bun test && bun run test-types", 49 | "test-types": "tsd -t './src/index.ts' -f test/*.test-d.ts", 50 | "size": "size-limit", 51 | "copy-ts": "cp dist/index.d.mts dist/cjs/index.d.cts && cp dist/debug.d.mts dist/cjs/debug.d.cts", 52 | "build": "tsup && npm run copy-ts" 53 | }, 54 | "engines": { 55 | "node": " >=14.13.1 || >=16.0.0" 56 | }, 57 | "devDependencies": { 58 | "@size-limit/preset-small-lib": "^11.0.1", 59 | "@switz/eslint-config": "^10.0.2", 60 | "bun-types": "^1.0.21", 61 | "prettier": "^3.1.1", 62 | "size-limit": "^11.0.1", 63 | "tsd": "^0.30.2", 64 | "tsup": "^8.0.1", 65 | "typescript": "^5.3.3" 66 | }, 67 | "size-limit": [ 68 | { 69 | "path": "dist/driver.esm.js", 70 | "limit": "1 kB", 71 | "brotli": true 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /test/array.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic array test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = driver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: ['isNotRecorded', 'isUploading'], 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(demoButton.activeState).toBe('isUploading'); 25 | expect(demoButton.isDisabled).toBe(true); 26 | expect(demoButton.text).toBe('Demo Uploading...'); 27 | }); 28 | 29 | test('basic enum test2', () => { 30 | const demoNotRecorded = false; 31 | 32 | const demoButton = driver({ 33 | states: { 34 | isNotRecorded: demoNotRecorded, 35 | isUploading: false, 36 | isUploaded: true, 37 | }, 38 | derived: { 39 | isDisabled: ['isNotRecorded', 'isUploading'], 40 | text: { 41 | isNotRecorded: 'Demo Disabled', 42 | isUploading: 'Demo Uploading...', 43 | isUploaded: 'Download Demo', 44 | }, 45 | }, 46 | }); 47 | 48 | expect(demoButton.activeState).toBe('isUploaded'); 49 | expect(demoButton.isDisabled).toBe(false); 50 | expect(demoButton.text).toBe('Download Demo'); 51 | }); 52 | 53 | test('array test when no state value is true', () => { 54 | const demoButton = driver({ 55 | states: { 56 | isNotRecorded: false, 57 | isUploading: false, 58 | isUploaded: false, 59 | }, 60 | derived: { 61 | isDisabled: ['isNotRecorded', 'isUploading'], 62 | text: { 63 | isNotRecorded: 'Demo Disabled', 64 | isUploading: 'Demo Uploading...', 65 | isUploaded: 'Download Demo', 66 | }, 67 | }, 68 | }); 69 | 70 | expect(demoButton.activeState).toBe(undefined); 71 | expect(demoButton.isDisabled).toBe(false); 72 | expect(demoButton.text).toBe(undefined); 73 | }); 74 | -------------------------------------------------------------------------------- /test/enums.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic enum test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = driver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: (_, enums, activeEnum) => (activeEnum ?? 0) <= enums.isUploading, 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(demoButton.activeState).toBe('isUploading'); 25 | expect(demoButton.isDisabled).toBe(true); 26 | expect(demoButton.text).toBe('Demo Uploading...'); 27 | }); 28 | 29 | test('basic enum test2', () => { 30 | const demoNotRecorded = false; 31 | 32 | const demoButton = driver({ 33 | states: { 34 | isNotRecorded: demoNotRecorded, 35 | isUploading: false, 36 | isUploaded: true, 37 | }, 38 | derived: { 39 | isDisabled: (_, enums, activeEnum) => (activeEnum ?? 0) <= enums.isUploading, 40 | text: { 41 | isNotRecorded: 'Demo Disabled', 42 | isUploading: 'Demo Uploading...', 43 | isUploaded: 'Download Demo', 44 | }, 45 | }, 46 | }); 47 | 48 | expect(demoButton.activeState).toBe('isUploaded'); 49 | expect(demoButton.isDisabled).toBe(false); 50 | expect(demoButton.text).toBe('Download Demo'); 51 | }); 52 | 53 | test('enums: when no state is true', () => { 54 | const demoButton = driver({ 55 | states: { 56 | isNotRecorded: false, 57 | isUploading: false, 58 | isUploaded: false, 59 | }, 60 | derived: { 61 | isDisabled: (_, enums, activeEnum) => (activeEnum ?? 0) <= enums.isUploading, 62 | text: { 63 | isNotRecorded: 'Demo Disabled', 64 | isUploading: 'Demo Uploading...', 65 | isUploaded: 'Download Demo', 66 | }, 67 | }, 68 | }); 69 | 70 | expect(demoButton.activeState).toBe(undefined); 71 | expect(demoButton.isDisabled).toBe(true); 72 | expect(demoButton.text).toBe(undefined); 73 | }); 74 | -------------------------------------------------------------------------------- /test/fns.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic fn test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = driver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: (states) => (states.isNotRecorded || states.isUploading ? 1000 : 10), 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(demoButton.activeState).toBe('isUploading'); 25 | expect(demoButton.isDisabled).toBe(1000); 26 | expect(demoButton.text).toBe('Demo Uploading...'); 27 | }); 28 | 29 | test('basic fn test 2', () => { 30 | const demoNotRecorded = false; 31 | 32 | const demoButton = driver({ 33 | states: { 34 | isNotRecorded: demoNotRecorded, 35 | isUploading: false, 36 | isUploaded: true, 37 | }, 38 | derived: { 39 | isDisabled: (states) => (states.isNotRecorded || states.isUploading ? 1000 : 10), 40 | text: { 41 | isNotRecorded: 'Demo Disabled', 42 | isUploading: 'Demo Uploading...', 43 | isUploaded: 'Download Demo', 44 | }, 45 | }, 46 | }); 47 | 48 | expect(demoButton.activeState).toBe('isUploaded'); 49 | expect(demoButton.isDisabled).toBe(10); 50 | expect(demoButton.text).toBe('Download Demo'); 51 | }); 52 | 53 | test('fns: when no state is true', () => { 54 | const demoButton = driver({ 55 | states: { 56 | isNotRecorded: false, 57 | isUploading: false, 58 | isUploaded: false, 59 | }, 60 | derived: { 61 | isDisabled: (states) => (states.isNotRecorded || states.isUploading ? 1000 : 10), 62 | text: { 63 | isNotRecorded: 'Demo Disabled', 64 | isUploading: 'Demo Uploading...', 65 | isUploaded: 'Download Demo', 66 | }, 67 | }, 68 | }); 69 | 70 | expect(demoButton.activeState).toBe(undefined); 71 | expect(demoButton.isDisabled).toBe(10); 72 | expect(demoButton.text).toBe(undefined); 73 | }); 74 | -------------------------------------------------------------------------------- /test/ai.test.ts: -------------------------------------------------------------------------------- 1 | // these were written with ai? 2 | 3 | import { test, expect } from 'bun:test'; 4 | import driver from '../src/index.js'; 5 | 6 | test('state key ordering', () => { 7 | const result = driver({ 8 | states: { a1: true, a2: false }, 9 | }); 10 | expect(result.activeState).toBe('a1'); 11 | expect(result.activeEnum).toBe(0); 12 | expect(Object.keys(result.enums)).toEqual(['a1', 'a2']); 13 | }); 14 | 15 | test('derived keys with undefined values', () => { 16 | const result = driver({ 17 | states: { a: true, b: false }, 18 | derived: { foo: undefined }, 19 | }); 20 | expect(result.foo).toBeUndefined(); 21 | }); 22 | 23 | test('derived keys with falsy values', () => { 24 | const result = driver({ 25 | states: { a: true, b: false }, 26 | derived: { foo: 0, bar: false }, 27 | }); 28 | expect(result.foo).toBe(0); 29 | expect(result.bar).toBe(false); 30 | }); 31 | 32 | test('derived keys with functions', () => { 33 | const result = driver({ 34 | states: { a: true, b: false }, 35 | derived: { foo: (states, enums) => Object.keys(states).length }, 36 | }); 37 | expect(result.foo).toBe(2); 38 | }); 39 | 40 | test('derived keys with arrays', () => { 41 | const result = driver({ 42 | states: { a: true, b: false }, 43 | derived: { foo: ['a', 'c'] }, 44 | }); 45 | expect(result.foo).toBe(true); 46 | }); 47 | 48 | test('derived keys with objects', () => { 49 | const result = driver({ 50 | states: { a: true, b: false }, 51 | derived: { foo: { a: 'bar', b: 'baz' } }, 52 | }); 53 | expect(result.foo).toBe('bar'); 54 | }); 55 | 56 | test('empty states', () => { 57 | const result = driver({ states: {} }); 58 | expect(result.activeState).toBeUndefined(); 59 | expect(result.activeEnum).toBeUndefined(); 60 | expect(result.enums).toEqual({}); 61 | }); 62 | 63 | test('no active state', () => { 64 | const result = driver({ states: { a: false, b: false } }); 65 | expect(result.activeState).toBeUndefined(); 66 | expect(result.activeEnum).toBeUndefined(); 67 | }); 68 | 69 | test('invalid state key type', () => { 70 | expect(() => driver({ states: { 1: true } })).toThrow(); 71 | }); 72 | 73 | test('invalid derived key type', () => { 74 | expect(() => 75 | driver({ 76 | states: { a: true }, 77 | derived: { foo: 123 }, 78 | }) 79 | ).not.toThrow(); 80 | }); 81 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config, DerivedConfig, Return } from './types'; 2 | 3 | function driver>( 4 | config: Config 5 | ): Return { 6 | // find the first active state 7 | const stateKeys = Object.keys(config.states); 8 | const activeState = stateKeys.find((key) => !!config.states[key as T]) as T; 9 | 10 | // find the enum of the active state 11 | const activeEnum = activeState ? stateKeys.indexOf(activeState) : undefined; 12 | 13 | // setup all the enums 14 | const enums: Record = {}; 15 | stateKeys.forEach((key, index) => { 16 | enums[key] = index; 17 | 18 | // state keys must be real strings (not integer strings) otherwise we can't guarantee ordering 19 | // see: https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order/38218582#38218582 20 | 21 | // this should be reliable in >= ES2020 22 | 23 | // symbols will also break ordering 24 | // TODO: should we check for them with `Object.getOwnPropertySymbols`? 25 | if (/^\d+$/.test(key)) throw new Error('State keys can not start with numbers: ' + key); 26 | }); 27 | 28 | // allow the use of the deprecated key flags for old code 29 | const derived = config.derived ?? config.flags; 30 | 31 | // map over every derived key 32 | const derivedData = derived 33 | ? Object.entries(derived).map(([key, value]) => { 34 | // if the value is a function, call the function w/ (states, enums, activeEnum) 35 | if (typeof value === 'function') { 36 | return [key, value(config.states, enums, activeEnum)]; 37 | } 38 | 39 | // if the value is an array, check if the active state is in it 40 | if (Array.isArray(value)) { 41 | return [key, value.includes(activeState)]; 42 | } 43 | 44 | // if the value is an object, lookup the active state's key 45 | if (typeof value === 'object') { 46 | return [key, value[activeState]]; 47 | } 48 | 49 | // otherwise just return the object as we started 50 | return [key, value]; 51 | }) 52 | : []; 53 | 54 | return Object.assign({}, Object.fromEntries(derivedData), { 55 | activeState, 56 | activeEnum, 57 | enums, 58 | states: config.states, 59 | }); 60 | } 61 | 62 | export default driver; 63 | -------------------------------------------------------------------------------- /test/types.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType, expectError } from 'tsd'; 2 | import driver from '../src/index'; 3 | 4 | const derived = driver({ 5 | states: { 6 | hello: true, 7 | foobar: false, 8 | test: undefined, 9 | }, 10 | derived: { 11 | isDisabled: (states) => states.hello, 12 | optionalParams: { 13 | foobar: 'hi', 14 | }, 15 | }, 16 | }); 17 | 18 | expectType(derived.isDisabled); 19 | expectType(derived.activeEnum); 20 | expectType<'hello' | 'foobar' | 'test' | undefined>(derived.activeState); 21 | expectType>(derived.enums); 22 | expectType(derived.optionalParams); 23 | 24 | const allDerived = driver({ 25 | states: { 26 | hello: false, 27 | foobar: false, 28 | test: undefined, 29 | }, 30 | derived: { 31 | params: { 32 | hello: 'hello', 33 | foobar: 'hi', 34 | test: 'foo', 35 | }, 36 | }, 37 | }); 38 | 39 | expectType(allDerived.params); 40 | 41 | // expect an error because no params are passed into a flag 42 | expectError( 43 | driver({ 44 | states: { 45 | hello: true, 46 | foobar: false, 47 | }, 48 | derived: { 49 | isDisabled: (states) => states.hello, 50 | noParams: {}, 51 | }, 52 | }) 53 | ); 54 | 55 | // protected derived keys 56 | expectError( 57 | driver({ 58 | states: { 59 | hello: true, 60 | foobar: false, 61 | }, 62 | derived: { 63 | enums: { 64 | foo: 1, 65 | }, 66 | }, 67 | }) 68 | ); 69 | 70 | expectError( 71 | driver({ 72 | states: { 73 | hello: true, 74 | foobar: false, 75 | }, 76 | derived: { 77 | activeEnum: { 78 | foo: 1, 79 | }, 80 | }, 81 | }) 82 | ); 83 | expectError( 84 | driver({ 85 | states: { 86 | hello: true, 87 | foobar: false, 88 | }, 89 | derived: { 90 | states: { 91 | foo: 1, 92 | }, 93 | }, 94 | }) 95 | ); 96 | expectError( 97 | driver({ 98 | states: { 99 | hello: true, 100 | foobar: false, 101 | }, 102 | derived: { 103 | activeState: { 104 | foo: 1, 105 | }, 106 | }, 107 | }) 108 | ); 109 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | 3 | import driver from '../src/index.js'; 4 | 5 | test('basic test', () => { 6 | const demoNotRecorded = false; 7 | 8 | const demoButton = driver({ 9 | states: { 10 | isNotRecorded: demoNotRecorded, 11 | isUploading: true, 12 | isUploaded: false, 13 | }, 14 | derived: { 15 | isDisabled: (states) => states.isNotRecorded || states.isUploading, 16 | text: { 17 | isNotRecorded: 'Demo Disabled', 18 | isUploading: 'Demo Uploading...', 19 | isUploaded: 'Download Demo', 20 | }, 21 | }, 22 | }); 23 | 24 | expect(demoButton.activeState).toBe('isUploading'); 25 | expect(demoButton.isDisabled).toBe(true); 26 | expect(demoButton.text).toBe('Demo Uploading...'); 27 | }); 28 | 29 | test('basic test that flags still works', () => { 30 | const demoNotRecorded = false; 31 | 32 | const demoButton = driver({ 33 | states: { 34 | isNotRecorded: demoNotRecorded, 35 | isUploading: true, 36 | isUploaded: false, 37 | }, 38 | flags: { 39 | isDisabled: (states) => states.isNotRecorded || states.isUploading, 40 | text: { 41 | isNotRecorded: 'Demo Disabled', 42 | isUploading: 'Demo Uploading...', 43 | isUploaded: 'Download Demo', 44 | }, 45 | }, 46 | }); 47 | 48 | expect(demoButton.activeState).toBe('isUploading'); 49 | expect(demoButton.isDisabled).toBe(true); 50 | expect(demoButton.text).toBe('Demo Uploading...'); 51 | }); 52 | 53 | test('ensure order works test', () => { 54 | const demoButton = driver({ 55 | states: { 56 | isNotRecorded: true, 57 | isUploading: true, 58 | isUploaded: false, 59 | }, 60 | derived: { 61 | isDisabled: (state) => state.isNotRecorded || state.isUploading, 62 | text: { 63 | isNotRecorded: 'Demo Disabled', 64 | isUploading: 'Demo Uploading...', 65 | isUploaded: 'Download Demo', 66 | }, 67 | }, 68 | }); 69 | 70 | expect(demoButton.activeState).toBe('isNotRecorded'); 71 | expect(demoButton.isDisabled).toBe(true); 72 | expect(demoButton.text).toBe('Demo Disabled'); 73 | }); 74 | 75 | test('when no state is true', () => { 76 | const demoButton = driver({ 77 | states: { 78 | isNotRecorded: false, 79 | isUploading: false, 80 | isUploaded: false, 81 | }, 82 | derived: { 83 | isDisabled: (state) => state.isNotRecorded || state.isUploading, 84 | text: { 85 | isNotRecorded: 'Demo Disabled', 86 | isUploading: 'Demo Uploading...', 87 | isUploaded: 'Download Demo', 88 | }, 89 | }, 90 | }); 91 | 92 | expect(demoButton.activeState).toBe(undefined); 93 | expect(demoButton.isDisabled).toBe(false); 94 | expect(demoButton.text).toBe(undefined); 95 | }); 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | .DS_Store 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | 17 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 18 | 19 | # Runtime data 20 | 21 | pids 22 | _.pid 23 | _.seed 24 | \*.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | 32 | coverage 33 | \*.lcov 34 | 35 | # nyc test coverage 36 | 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | 41 | .grunt 42 | 43 | # Bower dependency directory (https://bower.io/) 44 | 45 | bower_components 46 | 47 | # node-waf configuration 48 | 49 | .lock-wscript 50 | 51 | # Compiled binary addons (https://nodejs.org/api/addons.html) 52 | 53 | build/Release 54 | 55 | # Dependency directories 56 | 57 | node_modules/ 58 | jspm_packages/ 59 | 60 | # Snowpack dependency directory (https://snowpack.dev/) 61 | 62 | web_modules/ 63 | 64 | # TypeScript cache 65 | 66 | \*.tsbuildinfo 67 | 68 | # Optional npm cache directory 69 | 70 | .npm 71 | 72 | # Optional eslint cache 73 | 74 | .eslintcache 75 | 76 | # Optional stylelint cache 77 | 78 | .stylelintcache 79 | 80 | # Microbundle cache 81 | 82 | .rpt2_cache/ 83 | .rts2_cache_cjs/ 84 | .rts2_cache_es/ 85 | .rts2_cache_umd/ 86 | 87 | # Optional REPL history 88 | 89 | .node_repl_history 90 | 91 | # Output of 'npm pack' 92 | 93 | \*.tgz 94 | 95 | # Yarn Integrity file 96 | 97 | .yarn-integrity 98 | 99 | # dotenv environment variable files 100 | 101 | .env 102 | .env.development.local 103 | .env.test.local 104 | .env.production.local 105 | .env.local 106 | 107 | # parcel-bundler cache (https://parceljs.org/) 108 | 109 | .cache 110 | .parcel-cache 111 | 112 | # Next.js build output 113 | 114 | .next 115 | out 116 | 117 | # Nuxt.js build / generate output 118 | 119 | .nuxt 120 | dist 121 | 122 | # Gatsby files 123 | 124 | .cache/ 125 | 126 | # Comment in the public line in if your project uses Gatsby and not Next.js 127 | 128 | # https://nextjs.org/blog/next-9-1#public-directory-support 129 | 130 | # public 131 | 132 | # vuepress build output 133 | 134 | .vuepress/dist 135 | 136 | # vuepress v2.x temp and cache directory 137 | 138 | .temp 139 | .cache 140 | 141 | # Docusaurus cache and generated files 142 | 143 | .docusaurus 144 | 145 | # Serverless directories 146 | 147 | .serverless/ 148 | 149 | # FuseBox cache 150 | 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | 155 | .dynamodb/ 156 | 157 | # TernJS port file 158 | 159 | .tern-port 160 | 161 | # Stores VSCode versions used for testing VSCode extensions 162 | 163 | .vscode-test 164 | 165 | # yarn v2 166 | 167 | .yarn/cache 168 | .yarn/unplugged 169 | .yarn/build-state.yml 170 | .yarn/install-state.gz 171 | .pnp.\* 172 | 173 | lib/ 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🏁 driver

3 | 4 | [![Build](https://github.com/switz/driver/actions/workflows/release.yaml/badge.svg)](https://github.com/switz/driver/actions/workflows/release.yaml) ![npm (scoped)](https://img.shields.io/npm/v/@switz/driver?color=blue) ![npm bundle size (scoped)](https://img.shields.io/bundlejs/size/@switz/driver?color=green) 5 |
6 | 7 | driver is a tiny typescript utility for organizing external data into finite states and deriving common values. 8 | 9 | Jump to [sample code](https://github.com/switz/driver?tab=readme-ov-file#-sample-code) or the [docs](https://github.com/switz/driver?tab=readme-ov-file#-docs). Get help & support in [the Discord](https://discord.gg/dAKQQEDg9W). 10 | 11 |

✨ Features

12 | 13 | - Tiny with zero dependencies (<500B _gzipped + minified_) 14 | - Framework agnostic (works with react, svelte, vue, node, deno, bun, cloudflare workers, etc.) 15 | - Fully typed 16 | - Declarative API 17 | - Readable source code (~60 lines including comments) 18 | 19 |

📦 Installation

20 | 21 | ```bash 22 | $ npm i @switz/driver 23 | ``` 24 | 25 |

🍬 Sample Code

26 | 27 | This example is React, but driver is library agnostic. 28 | 29 | ```javascript 30 | import driver from '@switz/driver'; 31 | 32 | const CheckoutButton = ({ items, isLoading, checkout }) => { 33 | const shoppingCart = driver({ 34 | // the first truthy state is the active state 35 | states: { 36 | isLoading, 37 | isCartEmpty: items.length === 0, 38 | isCartValid: true, // fallback/default 39 | }, 40 | derived: { 41 | // isDisabled resolves to a boolean if the state matches 42 | isDisabled: ['isLoading', 'isCartEmpty'], 43 | // intent resolves to the value of the active state (a string here) 44 | popover: { 45 | isCartEmpty: 'Your cart is empty, please add items', 46 | }, 47 | intent: { 48 | isLoading: 'none', 49 | isCartEmpty: 'error', 50 | isCartValid: 'success', 51 | } 52 | }, 53 | }); 54 | 55 | return ( 56 | 57 | 64 | 65 | ); 66 | } 67 | ``` 68 | 69 | And we can represent our logic and ui as a truth table: 70 | 71 | | | isDisabled | intent | popover | 72 | |------------:|:----------:|:-------:|----------------| 73 | | isLoading | true | none | | 74 | | isCartEmpty | true | error | "Your cart..." | 75 | | isCartValid | false | success | | 76 | 77 | 78 | ## 👩‍🏭 Basic Introduction 79 | 80 | Each driver works by defining finite states. Only **one** state can be active at any given time. The first state to resolve to `true` is active. 81 | 82 | Let's look at some examples. I'm going to use React, but you don't have to. 83 | 84 | We define the possible states in the `states` object. The first state value to be true is the *active state* (these are akin to if/else statements). 85 | 86 | ```javascript 87 | import driver from '@switz/driver'; 88 | 89 | const CheckoutButton = ({ cartData }) => { 90 | const button = driver({ 91 | states: { 92 | isEmpty: cartData.items.length === 0, 93 | canCheckout: cartData.items.length > 0, 94 | }, 95 | derived: { 96 | // if the active state matches any strings in the array, `isDisabled` returns true 97 | isDisabled: ['isEmpty'], 98 | }, 99 | }); 100 | 101 | return ( 102 | 105 | ); 106 | } 107 | ``` 108 | 109 | Since driver gives us some guardrails to our stateful logic, they can be reflected as state tables: 110 | 111 | | States | isDisabled | 112 | | ------------- | ------------- | 113 | | isEmpty | true | 114 | | canCheckout | false | 115 | 116 | Here we have two possible states: `isEmpty` or `canCheckout` and one derived value from each state: isDisabled. 117 | 118 | Now you're probably thinking – this is over-engineering! We only have two states, why not just do this: 119 | 120 | ```javascript 121 | const CheckoutButton = ({ cartItems }) => { 122 | const isEmpty = cartItems.length === 0; 123 | 124 | return ( 125 | 128 | ); 129 | } 130 | ``` 131 | 132 | And in many ways you'd be right. But as your logic and code grows, you'll very quickly end up going from a single boolean flag to a mishmash of many. What happens when we add a third, or fourth state, and more derived values? What happens when we nest states? You can quickly go from 2 possible states to perhaps 12, 24, or many many more even in the simplest of components. 133 | 134 | Here's a more complex example with 4 states and 3 derived values. Can you see how giving our state some rigidity could reduce logic bugs? 135 | 136 | ```javascript 137 | const CheckoutButton = ({ cartItems, isLoading, checkout }) => { 138 | const cartValidation = validation(cartItems); 139 | const shoppingCart = driver({ 140 | states: { 141 | isLoading, 142 | isCartEmpty: cartItems.length === 0, 143 | isCartInvalid: !!cartValidation.isError, 144 | isCartValid: true, // fallback/default 145 | }, 146 | derived: { 147 | popoverText: { 148 | // unspecified states (isLoading, isCartValid here) default to undefined 149 | isCartEmpty: 'Your shopping cart is empty, add items to checkout', 150 | isCartInvalid: 'Your shopping cart has errors: ' + cartValidation.errorText, 151 | }, 152 | buttonVariant: { 153 | isLoading: 'info', 154 | isCartEmpty: 'info', 155 | isCartInvalid: 'error', 156 | isCartValid: 'primary', 157 | }, 158 | // onClick will be undefined except `ifCartValid` is true 159 | // 171 | 172 | ); 173 | } 174 | ``` 175 | 176 | What does this state table look like? 177 | 178 | | States | popoverText | buttonVariant | onClick | 179 | | ------------- | ------------- | ------------- | ------------- | 180 | | isLoading | | info | | 181 | | isCartEmpty | "Your shopping cart is empty..." | info | | 182 | | isCartInvalid | "Your shopping cart has errors..." | error | | 183 | | isCartValid | | primary | () => checkout | 184 | 185 | Putting it in table form displays the rigidity of the logic that we're designing. 186 | 187 | ## 🖼️ Background 188 | 189 | After working with state machines, I realized the benefits of giving your state rigidity. I noticed that I was tracking UI states via a plethora of boolean values, often intermixing const/let declarations with inline ternary logic. This is often inevitable when working with stateful UI libraries like react. 190 | 191 | Even though state machines are very useful, I also realized that my UI state is largely derived from boolean logic (via API data or React state) and not from a state machine I want to build and manually transition myself. So let's take out the machine part and just reflect common stateful values. 192 | 193 | For example, a particular button component may have several states, but will always need to know: 194 | 195 | 1. is the button disabled/does it have an onClick handler? 196 | 2. what is the button text? 197 | 3. what is the button's style/variant/intent, depending on if its valid or not? 198 | 199 | and other common values like 200 | 201 | 4. what is the popover/warning text if the button is disabled? 202 | 203 | By segmenting our UIs into explicit states, we can design and extend our UIs in a more pragmatic and extensible way. Logic is easier to reason about, organize, and test – and we can extend that logic without manipulating inline ternary expressions or fighting long lists of complex boolean logic. 204 | 205 | Maybe you have written (or had to modify), code that looks like this: 206 | 207 | ```javascript 208 | const CheckoutButton = ({ cartItems, isLoading }) => { 209 | const cartValidation = validation(cartItems); 210 | 211 | let popoverText = 'Your shopping cart is empty, add items to checkout'; 212 | let buttonVariant = 'info'; 213 | let isDisabled = true; 214 | 215 | if (cartValidation.isError) { 216 | popoverText = 'Your shopping cart has errors: ' + cartValidation.errorText; 217 | buttonVariant = 'error'; 218 | } 219 | else if (cartValidation.hasItems) { 220 | popoverText = null; 221 | isDisabled = false; 222 | buttonVariant = 'primary'; 223 | } 224 | 225 | return ( 226 | 227 | 230 | 231 | ); 232 | } 233 | ``` 234 | 235 | Touching this code is a mess, keeping track of the state tree is hard, and interleaving state values, boolean logic, and so on is cumbersome. You could write this a million different ways. 236 | 237 | Not to mention the implicit initial state that the default values imply the cart is empty. This state is essentially hidden to anyone reading the code. You could write this better – but you could also write it even worse. By using driver, your states are much more clearly delineated. 238 | 239 | 240 | ## Other examples: 241 | 242 | Every _driver_ contains a single active state. The first key in `states` to be true is the active state. 243 | 244 | ```javascript 245 | const DownloadButton = ({ match }) => { 246 | const demoButton = driver({ 247 | states: { 248 | isNotRecorded: !!match.config.dontRecord, 249 | isUploading: !match.demo_uploaded, 250 | isUploaded: !!match.demo_uploaded, 251 | }, 252 | derived: { 253 | isDisabled: ['isNotRecorded', 'isUploading'], 254 | // could also write this as: 255 | // isDisabled: (states) => states.isNotRecorded || states.isUploading, 256 | text: { 257 | isNotRecorded: 'Demo Disabled', 258 | isUploading: 'Demo Uploading...', 259 | isUploaded: 'Download Demo', 260 | }, 261 | }, 262 | }); 263 | 264 | return ( 265 | 268 | ); 269 | } 270 | ``` 271 | 272 | The derived data is pulled from the state keys. You can pass a function (and return any value), an array to mark boolean derived flags, or you can pass an object with the state keys, and whatever the current state key is will return that value. 273 | 274 | `isDisabled` is true if any of the specified state keys are active, whereas `text` returns whichever string corresponds directly to the currently active state value. 275 | 276 | Now instead of tossing ternary statements and if else and tracking messy declarations, all of your ui state can be derived through a simpler and concise state-machine inspired pattern. 277 | 278 | The goal here is not to have _zero_ logic inside of your actual view, but to make it easier and more maintainable to design and build your view logic in some more complex situations. 279 | 280 | ## 👾 Docs 281 | 282 | The `driver` function takes an object parameter with two keys: `states` and `derived`. 283 | 284 | ```javascript 285 | driver({ 286 | states: { 287 | state1: false, 288 | state2: true, 289 | }, 290 | derived: { 291 | text: { 292 | state1: 'State 1!', 293 | state2: 'State 2!', 294 | } 295 | } 296 | }) 297 | ``` 298 | 299 | `states` is an object whose keys are the potential state values. Passing dynamic boolean values into these keys dictates which state key is currently active. The first key with a truthy value is the active state. 300 | 301 | `derived` is an object whose keys derive their values from what the current state key is. There are three interfaces for the `derived` object. 302 | 303 | ### States 304 | 305 | ```javascript 306 | driver({ 307 | states: { 308 | isNotRecorded: match.config.dontRecord, 309 | isUploading: !match.demo_uploaded, 310 | isUploaded: match.demo_uploaded, 311 | }, 312 | }); 313 | ``` 314 | 315 | ### Derived 316 | 317 | #### Function 318 | 319 | You can return any value you'd like out of the function using the state keys 320 | 321 | ```diff 322 | driver({ 323 | states: { 324 | isNotRecorded: match.config.dontRecord, 325 | isUploading: !match.demo_uploaded, 326 | isUploaded: match.demo_uploaded, 327 | }, 328 | + derived: { 329 | + isDisabled: (states) => states.isNotRecorded || states.isUploading, 330 | + } 331 | }) 332 | ``` 333 | 334 | or you can access generated enums for more flexible logic 335 | 336 | 337 | ```diff 338 | driver({ 339 | states: { 340 | isNotRecorded: match.config.dontRecord, 341 | isUploading: !match.demo_uploaded, 342 | isUploaded: match.demo_uploaded, 343 | }, 344 | derived: { 345 | + isDisabled: (_, enums, activeEnum) => (activeEnum ?? 0) <= enums.isUploading, 346 | } 347 | }) 348 | ``` 349 | 350 | This declares that any state key _above_ isUploaded means the button is disabled (in this case, `isNotRecorded` and `isUploading`). This is useful for when you have delinated states and you want to more dynamically define where those lines are. 351 | 352 | #### Array 353 | 354 | By using an array, you can specify a boolean if any item in the array matches the current state: 355 | 356 | 357 | ```diff 358 | driver({ 359 | states: { 360 | isNotRecorded: match.config.dontRecord, 361 | isUploading: !match.demo_uploaded, 362 | isUploaded: match.demo_uploaded, 363 | }, 364 | derived: { 365 | + isDisabled: ['isNotRecorded', 'isUploading'], 366 | } 367 | }) 368 | ``` 369 | 370 | This returns true if the active state is: `isNotRecorded` or `isUploading`. 371 | 372 | This is the same as writing: `(states) => states.isNotRecorded || states.isUploading` in the function API above. 373 | 374 | #### Object Lookup 375 | 376 | If you want to have an independent value per active state, an object map is the easiest way. Each state key returns its value if it is the active state. For Example: 377 | 378 | 379 | ```diff 380 | driver({ 381 | states: { 382 | isNotRecorded: match.config.dontRecord, 383 | isUploading: !match.demo_uploaded, 384 | isUploaded: match.demo_uploaded, 385 | }, 386 | derived: { 387 | + text: { 388 | + isNotRecorded: 'Demo Disabled', 389 | + isUploading: 'Demo Uploading...', 390 | + isUploaded: 'Download Demo', 391 | + }, 392 | } 393 | }) 394 | ``` 395 | 396 | If the current state is `isNotRecorded` then the `text` key will return `'Demo Disabled'`. 397 | 398 | `isUploading` will return `'Demo Uploading...'`, and `isUploaded` will return `'Download Demo'`. 399 | 400 | 401 | ### Svelte Example 402 | 403 | This is a button with unique text that stops working at 10 clicks. Just prepend the driver call with `$: ` to mark it as reactive. 404 | 405 | ```javascript 406 | 431 | 432 | 435 | ``` 436 | 437 | ## Key Ordering Consistency 438 | 439 | My big concern here was abusing the ordering of object key ordering. Since the order of your `states object matters, I was worried that javascript may not respect key ordering. 440 | 441 | According to: https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order/38218582#38218582 442 | 443 | > Property order in normal Objects is a complex subject in JavaScript. 444 | > 445 | > While in ES5 explicitly no order has been specified, ES2015 defined an order in certain cases, and successive changes to the specification since have increasingly defined the order (even, as of ES2020, the for-in loop's order). 446 | > 447 | > This results in the following order (in certain cases): 448 | > 449 | > ``` 450 | > Object { 451 | > 0: 0, 452 | > 1: "1", 453 | > 2: "2", 454 | > b: "b", 455 | > a: "a", 456 | > m: function() {}, 457 | > Symbol(): "sym" 458 | > } 459 | > ``` 460 | > The order for "own" (non-inherited) properties is: 461 | > 462 | > Positive integer-like keys in ascending order 463 | > String keys in insertion order 464 | > Symbols in insertion order 465 | > 466 | > https://tc39.es/ecma262/#sec-ordinaryownpropertykeys 467 | 468 | Due to this, we force you to define your `states` keys as strings and only strings. This should prevent breaking the ordering of your state keys in modern javascript environments. 469 | 470 | If you feel this is wrong, please open an issue and show me how we can improve it. 471 | 472 | ## Help and Support 473 | 474 | Join the Discord for help: https://discord.gg/dAKQQEDg9W 475 | 476 | ## Warning: this is naive and changing 477 | 478 | This is still pretty early, the API surface may change. Code you write with this pattern may end up being _less_ efficient than before, with the hope that it reduces your logic bugs. This code is not _lazy_, so you may end up evaluating far more than you need for a given component. In my experience, you should not reach for a `driver` _immediately_, but as you see it fitting in, use it where it is handy. The _leafier_ the component (meaning further down the tree, closer to the bottom), the more useful I've found it. 479 | 480 | ## Typescript 481 | 482 | This library is fully typed end-to-end. That said, this is the first time I've typed a library of this kind and it could definitely be improved. If you run into an issue, please raise it or submit a PR. 483 | 484 | ## Local Development 485 | 486 | To install dependencies: 487 | 488 | ```bash 489 | bun install 490 | ``` 491 | 492 | To test: 493 | 494 | ```bash 495 | npm run test # we test the typescript types on top of basic unit tests 496 | ``` 497 | --------------------------------------------------------------------------------