├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── biome.json ├── eslint.config.js ├── example ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── .nojekyll │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.jsx │ ├── background.png │ ├── index.css │ └── index.jsx └── vite.config.js ├── package-lock.json ├── package.json ├── readme.md ├── src ├── components │ ├── bang.tsx │ ├── ezdac.tsx │ ├── message.tsx │ ├── object.tsx │ ├── playbar.tsx │ ├── radiogroup.tsx │ ├── slider.tsx │ ├── textbutton.tsx │ ├── toggle.tsx │ └── umenu.tsx ├── index.ts ├── scss │ ├── bang.module.scss │ ├── ezdac.module.scss │ ├── message.module.scss │ ├── object.module.scss │ ├── playbar.module.scss │ ├── radiogroup.module.scss │ ├── slider.module.scss │ ├── textbutton.module.scss │ ├── toggle.module.scss │ ├── umenu.module.scss │ └── utils.scss ├── svg │ ├── bang.svg │ ├── ezdac.svg │ ├── inlet.svg │ ├── playbar-pause.svg │ ├── playbar-play.svg │ ├── playbar-slider.svg │ ├── radiogroup-row.svg │ ├── toggle.svg │ └── umenu-arrow.svg └── types.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | all-tests: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [macos-latest] 15 | node: [22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@master 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node }} 22 | 23 | - name: Install node modules 24 | run: npm install --include=dev 25 | 26 | - name: Format project 27 | run: | 28 | npx @biomejs/biome format 29 | npx prettier --check ./src/scss 30 | npx prettier --check ./src/svg 31 | 32 | - name: Assert types 33 | run: npx tsc 34 | 35 | - name: Lint project 36 | run: | 37 | npx @biomejs/biome lint 38 | npx eslint . 39 | npx stylelint **/*.css **/*.scss 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # production 5 | build 6 | dist 7 | 8 | # misc 9 | .deno 10 | .DS_Store 11 | .vscode 12 | 13 | # package specific 14 | unused 15 | todo.md -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "ignore": ["**/node_modules/**", "**/dist/**", "**/unused/**"], 5 | "include": [ 6 | "**/*.css", 7 | "**/*.html", 8 | "**/*.js", 9 | "**/*.json", 10 | "**/*.jsx", 11 | "**/*.ts", 12 | "**/*.d.ts", 13 | "**/*.tsx" 14 | ] 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "all": true, 23 | "a11y": { 24 | "noNoninteractiveElementToInteractiveRole": "off", 25 | "noSvgWithoutTitle": "off", 26 | "useKeyWithClickEvents": "off", 27 | "useMediaCaption": "off", 28 | "useSemanticElements": "off" 29 | }, 30 | "complexity": { 31 | "noExcessiveCognitiveComplexity": "off", 32 | "noVoid": "off" 33 | }, 34 | "performance": { 35 | "noBarrelFile": "off" 36 | }, 37 | "style": { 38 | "noDefaultExport": "off", 39 | "noNamespaceImport": "off", 40 | "noParameterAssign": "off", 41 | "useNamingConvention": "off" 42 | }, 43 | "suspicious": { 44 | "noReactSpecificProps": "off" 45 | } 46 | } 47 | }, 48 | "formatter": { 49 | "enabled": true, 50 | "lineEnding": "lf", 51 | "lineWidth": 125, 52 | "indentWidth": 4, 53 | "indentStyle": "tab" 54 | }, 55 | "css": { 56 | "formatter": { 57 | "quoteStyle": "single" 58 | } 59 | }, 60 | "javascript": { 61 | "formatter": { 62 | "arrowParentheses": "always", 63 | "bracketSpacing": true, 64 | "bracketSameLine": false, 65 | "jsxQuoteStyle": "single", 66 | "quoteProperties": "asNeeded", 67 | "quoteStyle": "single", 68 | "semicolons": "asNeeded", 69 | "trailingCommas": "all" 70 | } 71 | }, 72 | "json": { 73 | "formatter": { 74 | "lineWidth": 100 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import reactHooks from 'eslint-plugin-react-hooks' 3 | import globals from 'globals' 4 | import tseslint from 'typescript-eslint' 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | reactHooks.configs['recommended-latest'], 9 | ...tseslint.configs.strictTypeChecked, 10 | ...tseslint.configs.stylisticTypeChecked, 11 | { 12 | ignores: ['**/dist/**', '**/unused/**'], 13 | }, 14 | { 15 | files: ['**/*.ts', '**/*.tsx'], 16 | languageOptions: { 17 | ecmaVersion: 'latest', 18 | parserOptions: { 19 | projectService: true, 20 | tsconfigRootDir: import.meta.dirname, 21 | }, 22 | }, 23 | }, 24 | { 25 | files: ['**/*.js', '**/*.jsx'], 26 | languageOptions: { 27 | globals: { 28 | ...globals.browser, 29 | }, 30 | }, 31 | extends: [tseslint.configs.disableTypeChecked], 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | maxmsp-gui 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxmsp-gui-example", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "maxmsp-gui-example", 9 | "version": "0.0.0", 10 | "dependencies": { 11 | "@vitejs/plugin-react-swc": "file:../node_modules/@vitejs/plugin-react-swc", 12 | "maxmsp-gui": "file:..", 13 | "react": "file:../node_modules/react", 14 | "react-dom": "file:../node_modules/react-dom", 15 | "vite": "file:../node_modules/vite", 16 | "vite-plugin-compression2": "file:../node_modules/vite-plugin-compression2" 17 | } 18 | }, 19 | "..": { 20 | "version": "2.5.2", 21 | "license": "Creative Commons SA", 22 | "devDependencies": { 23 | "@biomejs/biome": "^1.9.4", 24 | "@eslint/js": "^9.25.1", 25 | "@prettier/plugin-xml": "^3.4.1", 26 | "@types/node": "^22.14.1", 27 | "@types/react": "^19.1.2", 28 | "@types/react-dom": "^19.1.2", 29 | "@vitejs/plugin-react-swc": "^3.9.0", 30 | "eslint": "^9.25.1", 31 | "eslint-plugin-react-hooks": "^5.2.0", 32 | "gh-pages": "^6.3.0", 33 | "globals": "^16.0.0", 34 | "prettier": "^3.5.3", 35 | "sass": "^1.87.0", 36 | "stylelint": "^16.18.0", 37 | "stylelint-config-standard-scss": "^14.0.0", 38 | "typescript": "^5.8.3", 39 | "typescript-eslint": "^8.31.0", 40 | "vite": "^6.3.2", 41 | "vite-plugin-compression2": "^1.3.3", 42 | "vite-plugin-css-injected-by-js": "^3.5.2", 43 | "vite-plugin-dts": "^4.5.3", 44 | "vite-plugin-svgr": "^4.3.0" 45 | }, 46 | "engines": { 47 | "node": ">=10" 48 | }, 49 | "peerDependencies": { 50 | "react": "^19.1.0", 51 | "react-dom": "^19.1.0" 52 | } 53 | }, 54 | "../node_modules/@vitejs/plugin-react-swc": { 55 | "version": "3.9.0", 56 | "license": "MIT", 57 | "dependencies": { 58 | "@swc/core": "^1.11.21" 59 | }, 60 | "peerDependencies": { 61 | "vite": "^4 || ^5 || ^6" 62 | } 63 | }, 64 | "../node_modules/react": { 65 | "version": "19.1.0", 66 | "license": "MIT", 67 | "engines": { 68 | "node": ">=0.10.0" 69 | } 70 | }, 71 | "../node_modules/react-dom": { 72 | "version": "19.1.0", 73 | "license": "MIT", 74 | "dependencies": { 75 | "scheduler": "^0.26.0" 76 | }, 77 | "peerDependencies": { 78 | "react": "^19.1.0" 79 | } 80 | }, 81 | "../node_modules/vite": { 82 | "version": "6.3.2", 83 | "license": "MIT", 84 | "dependencies": { 85 | "esbuild": "^0.25.0", 86 | "fdir": "^6.4.3", 87 | "picomatch": "^4.0.2", 88 | "postcss": "^8.5.3", 89 | "rollup": "^4.34.9", 90 | "tinyglobby": "^0.2.12" 91 | }, 92 | "bin": { 93 | "vite": "bin/vite.js" 94 | }, 95 | "engines": { 96 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 97 | }, 98 | "funding": { 99 | "url": "https://github.com/vitejs/vite?sponsor=1" 100 | }, 101 | "optionalDependencies": { 102 | "fsevents": "~2.3.3" 103 | }, 104 | "peerDependencies": { 105 | "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 106 | "jiti": ">=1.21.0", 107 | "less": "*", 108 | "lightningcss": "^1.21.0", 109 | "sass": "*", 110 | "sass-embedded": "*", 111 | "stylus": "*", 112 | "sugarss": "*", 113 | "terser": "^5.16.0", 114 | "tsx": "^4.8.1", 115 | "yaml": "^2.4.2" 116 | }, 117 | "peerDependenciesMeta": { 118 | "@types/node": { 119 | "optional": true 120 | }, 121 | "jiti": { 122 | "optional": true 123 | }, 124 | "less": { 125 | "optional": true 126 | }, 127 | "lightningcss": { 128 | "optional": true 129 | }, 130 | "sass": { 131 | "optional": true 132 | }, 133 | "sass-embedded": { 134 | "optional": true 135 | }, 136 | "stylus": { 137 | "optional": true 138 | }, 139 | "sugarss": { 140 | "optional": true 141 | }, 142 | "terser": { 143 | "optional": true 144 | }, 145 | "tsx": { 146 | "optional": true 147 | }, 148 | "yaml": { 149 | "optional": true 150 | } 151 | } 152 | }, 153 | "../node_modules/vite-plugin-compression2": { 154 | "version": "1.3.3", 155 | "license": "MIT", 156 | "dependencies": { 157 | "@rollup/pluginutils": "^5.1.0", 158 | "tar-mini": "^0.2.0" 159 | }, 160 | "peerDependencies": { 161 | "vite": "^2.0.0||^3.0.0||^4.0.0||^5.0.0 ||^6.0.0" 162 | } 163 | }, 164 | "node_modules/@vitejs/plugin-react-swc": { 165 | "resolved": "../node_modules/@vitejs/plugin-react-swc", 166 | "link": true 167 | }, 168 | "node_modules/maxmsp-gui": { 169 | "resolved": "..", 170 | "link": true 171 | }, 172 | "node_modules/react": { 173 | "resolved": "../node_modules/react", 174 | "link": true 175 | }, 176 | "node_modules/react-dom": { 177 | "resolved": "../node_modules/react-dom", 178 | "link": true 179 | }, 180 | "node_modules/vite": { 181 | "resolved": "../node_modules/vite", 182 | "link": true 183 | }, 184 | "node_modules/vite-plugin-compression2": { 185 | "resolved": "../node_modules/vite-plugin-compression2", 186 | "link": true 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxmsp-gui-example", 3 | "homepage": ".", 4 | "version": "0.0.0", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite --host --base /", 9 | "build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@vitejs/plugin-react-swc": "file:../node_modules/@vitejs/plugin-react-swc", 14 | "maxmsp-gui": "file:..", 15 | "react": "file:../node_modules/react", 16 | "react-dom": "file:../node_modules/react-dom", 17 | "vite": "file:../node_modules/vite", 18 | "vite-plugin-compression2": "file:../node_modules/vite-plugin-compression2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewiswolf/maxmsp-gui/245b3aebe68983a9db0511d54e7202210bfc3862/example/public/.nojekyll -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewiswolf/maxmsp-gui/245b3aebe68983a9db0511d54e7202210bfc3862/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewiswolf/maxmsp-gui/245b3aebe68983a9db0511d54e7202210bfc3862/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewiswolf/maxmsp-gui/245b3aebe68983a9db0511d54e7202210bfc3862/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#e5e5e5", 3 | "description": "React component library for stylised Max MSP GUI", 4 | "developer": { 5 | "name": "Lewis Wolstanholme", 6 | "url": "http://lewiswolstanholme.co.uk" 7 | }, 8 | "display": "standalone", 9 | "icons": [ 10 | { 11 | "sizes": "64x64 32x32 24x24 16x16", 12 | "src": "favicon.ico", 13 | "type": "image/x-icon" 14 | }, 15 | { 16 | "sizes": "192x192", 17 | "src": "logo192.png", 18 | "type": "image/png" 19 | }, 20 | { 21 | "sizes": "512x512", 22 | "src": "logo512.png", 23 | "type": "image/png" 24 | } 25 | ], 26 | "name": "maxmsp-gui", 27 | "short_name": "maxmsp-gui", 28 | "start_url": ".", 29 | "theme_color": "#333333" 30 | } 31 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * -------------------------------------------------------------------------------- /example/src/App.jsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { useEffect, useRef, useState } from 'react' 3 | import * as MaxMSP from 'maxmsp-gui' 4 | 5 | export default function App() { 6 | // handle iframe styles 7 | const iframe = window !== window.top 8 | 9 | // example playbar animation 10 | const [playbar, setPlaybar] = useState(0) 11 | const [playing, setPlaying] = useState(false) 12 | const interval = useRef(null) 13 | useEffect(() => { 14 | if (playing) { 15 | interval.current = window.setInterval(() => { 16 | if (playbar <= 0.995) { 17 | setPlaybar(playbar + 0.005) 18 | } else { 19 | setPlaybar(0) 20 | setPlaying(false) 21 | return () => { 22 | window.clearInterval(interval.current) 23 | } 24 | } 25 | }, 10) 26 | } 27 | return () => { 28 | window.clearInterval(interval.current) 29 | } 30 | }, [playing, playbar]) 31 | 32 | // render page 33 | return ( 34 | <> 35 | {!iframe &&

React component library for stylised Max MSP GUI

} 36 | 37 | 38 | 39 | 40 | { 44 | setPlaybar(x) 45 | }} 46 | onPlay={(bool) => { 47 | setPlaying(bool) 48 | }} 49 | /> 50 | 51 | 52 |
53 | 54 | 55 |
56 | 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /example/src/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewiswolf/maxmsp-gui/245b3aebe68983a9db0511d54e7202210bfc3862/example/src/background.png -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Arial, sans-serif; 5 | background: transparent; 6 | } 7 | 8 | body { 9 | margin: 0; 10 | padding: 0; 11 | background-image: url('./background.png'); 12 | background-size: 30px 30px; 13 | background-color: #e5e5e5; 14 | } 15 | 16 | main { 17 | display: grid; 18 | grid-auto-rows: minmax(70px, max-content); 19 | place-items: center; 20 | padding-bottom: 48px; 21 | } 22 | 23 | h2 { 24 | font-family: inherit; 25 | user-select: none; 26 | padding: 0 5px; 27 | text-align: center; 28 | } 29 | -------------------------------------------------------------------------------- /example/src/index.jsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { StrictMode } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | // src 5 | import App from './App.jsx' 6 | import './index.css' 7 | 8 | createRoot(document.getElementById('root')).render( 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /example/vite.config.js: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc' 2 | import { defineConfig } from 'vite' 3 | import { compression } from 'vite-plugin-compression2' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '/maxmsp-gui', 8 | build: { target: 'ESNext' }, 9 | esbuild: { legalComments: 'none' }, 10 | plugins: [ 11 | compression({ 12 | algorithm: 'gzip', 13 | include: /\.(js|mjs|json|css|svg)$/i, 14 | }), 15 | react(), 16 | ], 17 | }) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxmsp-gui", 3 | "version": "2.5.2", 4 | "description": "React component library for stylised Max MSP GUI.", 5 | "author": "Lewis Wolstanholme", 6 | "license": "Creative Commons SA", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/lewiswolf/maxmsp-gui.git" 10 | }, 11 | "main": "dist/index.js", 12 | "source": "src/index.ts", 13 | "types": "dist/index.d.ts", 14 | "type": "module", 15 | "engines": { 16 | "node": ">=10" 17 | }, 18 | "keywords": ["cycling74", "max msp", "maxmsp"], 19 | "peerDependencies": { 20 | "react": "^19.1.0", 21 | "react-dom": "^19.1.0" 22 | }, 23 | "devDependencies": { 24 | "@biomejs/biome": "^1.9.4", 25 | "@eslint/js": "^9.25.1", 26 | "@prettier/plugin-xml": "^3.4.1", 27 | "@types/node": "^22.14.1", 28 | "@types/react": "^19.1.2", 29 | "@types/react-dom": "^19.1.2", 30 | "@vitejs/plugin-react-swc": "^3.9.0", 31 | "eslint": "^9.25.1", 32 | "eslint-plugin-react-hooks": "^5.2.0", 33 | "gh-pages": "^6.3.0", 34 | "globals": "^16.0.0", 35 | "prettier": "^3.5.3", 36 | "sass": "^1.87.0", 37 | "stylelint": "^16.18.0", 38 | "stylelint-config-standard-scss": "^14.0.0", 39 | "typescript": "^5.8.3", 40 | "typescript-eslint": "^8.31.0", 41 | "vite": "^6.3.2", 42 | "vite-plugin-compression2": "^1.3.3", 43 | "vite-plugin-css-injected-by-js": "^3.5.2", 44 | "vite-plugin-dts": "^4.5.3", 45 | "vite-plugin-svgr": "^4.3.0" 46 | }, 47 | "files": ["dist"], 48 | "scripts": { 49 | "build": "tsc && vite build", 50 | "dev": "vite build --watch", 51 | "predeploy": "npm run build && cd example && npm run build", 52 | "deploy": "gh-pages -d example/dist", 53 | "example": "cd example && npm run dev", 54 | "format": "biome format --write && prettier --write ./**/*.html ./**/*.md ./**/*.scss ./**/*.svg", 55 | "test": "tsc && biome lint && eslint . && stylelint **/*.css **/*.scss" 56 | }, 57 | "prettier": { 58 | "arrowParens": "always", 59 | "bracketSpacing": true, 60 | "bracketSameLine": false, 61 | "endOfLine": "lf", 62 | "htmlWhitespaceSensitivity": "strict", 63 | "jsxSingleQuote": true, 64 | "plugins": ["@prettier/plugin-xml"], 65 | "printWidth": 125, 66 | "quoteProps": "as-needed", 67 | "semi": false, 68 | "singleQuote": true, 69 | "tabWidth": 4, 70 | "trailingComma": "all", 71 | "useTabs": true, 72 | "xmlQuoteAttributes": "single", 73 | "xmlSortAttributesByKey": true, 74 | "xmlWhitespaceSensitivity": "preserve" 75 | }, 76 | "stylelint": { 77 | "extends": "stylelint-config-standard-scss", 78 | "ignoreFiles": ["**/dist/**"], 79 | "rules": { 80 | "declaration-empty-line-before": null, 81 | "no-descending-specificity": null, 82 | "property-no-vendor-prefix": null, 83 | "scss/double-slash-comment-empty-line-before": null 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # maxmsp-gui 2 | 3 | ## by Lewis Wolf 4 | 5 | > React component library for stylised Max MSP GUI. 6 | 7 | [![NPM](https://img.shields.io/npm/v/maxmsp-gui.svg)](https://www.npmjs.com/package/maxmsp-gui) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 8 | 9 | ### [Demo](https://lewiswolf.github.io/maxmsp-gui/) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm i maxmsp-gui 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```tsx 20 | import * as MaxMSP from 'maxmsp-gui' 21 | 22 | export default function App() { 23 | return ( 24 | <> 25 | console.log('bang')} 29 | /> 30 | 31 | console.log(b)} 35 | /> 36 | 37 | console.log('bang')} 42 | /> 43 | 44 | 48 | 49 | console.log(b)} 56 | onChange={(x: number) => console.log(`My value is ${x}`)} // 0 - props.fidelity 57 | /> 58 | 59 | console.log(`My index is ${i}`)} 65 | /> 66 | 67 | console.log(`My value is ${x}`)} // 0 - props.fidelity 72 | /> 73 | 74 | console.log(b)} // toggle mode only 83 | onClick={() => console.log('bang')} 84 | /> 85 | 86 | console.log(b)} 90 | /> 91 | 92 | console.log(`My index is ${i}`)} 98 | /> 99 | 100 | ) 101 | } 102 | ``` 103 | 104 | All components return a div, and so the css can be accessed/overwritten in the following way: 105 | 106 | ```css 107 | .wrapper div:nth-of-type(n) { 108 | // css goes here 109 | } 110 | ``` 111 | 112 | ## Development 113 | 114 | ### Install 115 | 116 | ```bash 117 | git clone ... 118 | npm install --include=dev 119 | ``` 120 | 121 | ### Run 122 | 123 | ```bash 124 | npm run build 125 | npm run example 126 | ``` 127 | 128 | ### Test 129 | 130 | ```bash 131 | npm run dev 132 | npm run format 133 | npm run test 134 | ``` 135 | -------------------------------------------------------------------------------- /src/components/bang.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/bang.module.scss' 6 | import SVG from '../svg/bang.svg?react' 7 | 8 | const Bang: FC<{ 9 | ariaLabel?: string 10 | ariaPressed?: boolean | null 11 | onClick?: () => void 12 | }> = ({ 13 | ariaLabel = 'bang', 14 | ariaPressed = null, 15 | onClick = () => { 16 | /* */ 17 | }, 18 | }) => { 19 | /* 20 | [bang] 21 | */ 22 | 23 | const self = useRef(null) 24 | // click event with prop 25 | const buttonPressed = (): void => { 26 | isMouseDown(true) 27 | onClick() 28 | } 29 | // mousedown state 30 | const [mousedown, isMouseDown] = useState(false) 31 | // this useEffect adds a global mouse up to allow for press and hover, 32 | // and a touchstart event used to prevent event bubbling. 33 | useEffect(() => { 34 | const buttonFreed = (): void => { 35 | isMouseDown(false) 36 | } 37 | const touchstart = (e: TouchEvent): void => { 38 | if (e.cancelable) { 39 | e.preventDefault() 40 | isMouseDown(true) 41 | onClick() 42 | } 43 | } 44 | if (self.current) { 45 | window.addEventListener('mouseup', buttonFreed) 46 | self.current.addEventListener('touchstart', touchstart) 47 | } 48 | const cleanup_self = self.current 49 | return () => { 50 | if (cleanup_self) { 51 | cleanup_self.removeEventListener('touchstart', touchstart) 52 | window.removeEventListener('mouseup', buttonFreed) 53 | } 54 | } 55 | }, [onClick]) 56 | 57 | return ( 58 |
{ 68 | if (e.key === 'Enter' || e.key === ' ') { 69 | e.preventDefault() 70 | if (!mousedown) { 71 | buttonPressed() 72 | } 73 | } 74 | }} 75 | onKeyUp={(e) => { 76 | if ((e.key === 'Enter' || e.key === ' ') && mousedown) { 77 | e.preventDefault() 78 | isMouseDown(false) 79 | } 80 | }} 81 | onMouseDown={(e) => { 82 | if (e.button === 0) { 83 | buttonPressed() 84 | } 85 | }} 86 | onTouchCancel={() => { 87 | isMouseDown(false) 88 | }} 89 | onTouchEnd={() => { 90 | isMouseDown(false) 91 | }} 92 | > 93 | 99 |
100 | ) 101 | } 102 | 103 | export default Bang 104 | -------------------------------------------------------------------------------- /src/components/ezdac.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/ezdac.module.scss' 6 | import SVG from '../svg/ezdac.svg?react' 7 | 8 | const Ezdac: FC<{ 9 | ariaLabel?: string 10 | setValue?: boolean 11 | onClick?: (b: boolean) => void 12 | }> = ({ 13 | ariaLabel = 'ezdac', 14 | setValue = false, 15 | onClick = () => { 16 | /**/ 17 | }, 18 | }) => { 19 | /* 20 | [ezdac] 21 | */ 22 | 23 | const self = useRef(null) 24 | // is the toggle pressed - state and prop 25 | const [pressed, isPressed] = useState(setValue) 26 | useEffect(() => { 27 | isPressed(setValue) 28 | }, [setValue]) 29 | // click event with prop 30 | const togglePressed = (): void => { 31 | isPressed(!pressed) 32 | onClick(pressed) 33 | } 34 | // keyboard event specific watch state 35 | const [keydown, isKeyDown] = useState(false) 36 | // this useEffect adds a touch event listener used to prevent bubbling. 37 | useEffect(() => { 38 | const touchstart = (e: TouchEvent): void => { 39 | if (e.cancelable) { 40 | e.preventDefault() 41 | togglePressed() 42 | } 43 | } 44 | self.current?.addEventListener('touchstart', touchstart) 45 | const cleanup_self = self.current 46 | return () => { 47 | cleanup_self?.removeEventListener('touchstart', touchstart) 48 | } 49 | }) 50 | 51 | return ( 52 |
{ 60 | if (e.key === 'Enter' || e.key === ' ') { 61 | e.preventDefault() 62 | if (!keydown) { 63 | isKeyDown(true) 64 | togglePressed() 65 | } 66 | } 67 | }} 68 | onKeyUp={(e) => { 69 | if ((e.key === 'Enter' || e.key === ' ') && keydown) { 70 | e.preventDefault() 71 | isKeyDown(false) 72 | } 73 | }} 74 | onMouseDown={(e) => { 75 | if (e.button === 0) { 76 | togglePressed() 77 | } 78 | }} 79 | > 80 | 86 |
87 | ) 88 | } 89 | 90 | export default Ezdac 91 | -------------------------------------------------------------------------------- /src/components/message.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/message.module.scss' 6 | 7 | const Message: FC<{ 8 | ariaLabel?: string 9 | ariaPressed?: boolean | null 10 | text?: string 11 | onClick?: () => void 12 | }> = ({ 13 | ariaLabel = 'message', 14 | ariaPressed = null, 15 | text = '', 16 | onClick = () => { 17 | /**/ 18 | }, 19 | }) => { 20 | /* 21 | [message] 22 | */ 23 | 24 | const self = useRef(null) 25 | // click event with prop 26 | const buttonPressed = (): void => { 27 | isMouseDown(true) 28 | onClick() 29 | } 30 | // mousedown state 31 | const [mousedown, isMouseDown] = useState(false) 32 | // this useEffect adds a global mouse up to allow for press and hover, 33 | // and a touchstart event used to prevent event bubbling. 34 | useEffect(() => { 35 | const buttonFreed = (): void => { 36 | isMouseDown(false) 37 | } 38 | const touchstart = (e: TouchEvent): void => { 39 | if (e.cancelable) { 40 | e.preventDefault() 41 | isMouseDown(true) 42 | onClick() 43 | } 44 | } 45 | if (self.current) { 46 | window.addEventListener('mouseup', buttonFreed) 47 | self.current.addEventListener('touchstart', touchstart) 48 | } 49 | const cleanup_self = self.current 50 | return () => { 51 | if (cleanup_self) { 52 | cleanup_self.removeEventListener('touchstart', touchstart) 53 | window.removeEventListener('mouseup', buttonFreed) 54 | } 55 | } 56 | }, [onClick]) 57 | 58 | return ( 59 |
{ 69 | if (e.key === 'Enter' || e.key === ' ') { 70 | e.preventDefault() 71 | if (!mousedown) { 72 | buttonPressed() 73 | } 74 | } 75 | }} 76 | onKeyUp={(e) => { 77 | if ((e.key === 'Enter' || e.key === ' ') && mousedown) { 78 | e.preventDefault() 79 | isMouseDown(false) 80 | } 81 | }} 82 | onMouseDown={(e) => { 83 | if (e.button === 0) { 84 | buttonPressed() 85 | } 86 | }} 87 | onTouchCancel={() => { 88 | isMouseDown(false) 89 | }} 90 | onTouchEnd={() => { 91 | isMouseDown(false) 92 | }} 93 | > 94 |

100 | {text} 101 |

102 |
103 | ) 104 | } 105 | 106 | export default Message 107 | -------------------------------------------------------------------------------- /src/components/object.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import type { FC } from 'react' 3 | 4 | // src 5 | import SVG from '../svg/inlet.svg?react' 6 | import style from '../scss/object.module.scss' 7 | 8 | const object: FC<{ 9 | inactive?: boolean 10 | text?: string 11 | }> = ({ inactive = false, text = '' }) => { 12 | /* 13 | [] 14 | */ 15 | 16 | return ( 17 |
18 | {!inactive && text && } 19 |

{text}

20 |
21 | ) 22 | } 23 | 24 | export default object 25 | -------------------------------------------------------------------------------- /src/components/playbar.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, type JSX, createElement, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/playbar.module.scss' 6 | import PauseButtonSVG from '../svg/playbar-pause.svg?react' 7 | import PlayButtonSVG from '../svg/playbar-play.svg?react' 8 | 9 | const PlaybarToggle: FC<{ 10 | ariaLabel: string 11 | inactive: boolean 12 | setPlaying: boolean 13 | onPlay: (b: boolean) => void 14 | }> = ({ ariaLabel, inactive, setPlaying, onPlay }): JSX.Element => { 15 | /* 16 | The toggle element of the playbar. 17 | */ 18 | const self = useRef(null) 19 | // is the toggle pressed - state and prop 20 | const [playing, isPlaying] = useState(setPlaying) 21 | useEffect(() => { 22 | isPlaying(setPlaying) 23 | }, [setPlaying]) 24 | // click event with prop 25 | const toggle = (): void => { 26 | isMouseDown(true) 27 | if (!inactive) { 28 | isPlaying(!playing) 29 | onPlay(!playing) 30 | } 31 | } 32 | // mousedown state 33 | const [mousedown, isMouseDown] = useState(false) 34 | // this useEffect adds a global mouse up to allow for press and hover, 35 | // and a touchstart event used to prevent event bubbling. 36 | useEffect(() => { 37 | const mouseup = (): void => { 38 | isMouseDown(false) 39 | } 40 | const touchstart = (e: TouchEvent): void => { 41 | if (e.cancelable) { 42 | e.preventDefault() 43 | toggle() 44 | } 45 | } 46 | if (self.current) { 47 | window.addEventListener('mouseup', mouseup) 48 | self.current.addEventListener('touchstart', touchstart) 49 | } 50 | const cleanup_self = self.current 51 | return () => { 52 | if (cleanup_self) { 53 | window.removeEventListener('mouseup', mouseup) 54 | cleanup_self.removeEventListener('touchstart', touchstart) 55 | } 56 | } 57 | }) 58 | 59 | return ( 60 |
{ 73 | if (e.key === 'Enter' || e.key === ' ') { 74 | e.preventDefault() 75 | if (!mousedown) { 76 | toggle() 77 | } 78 | } 79 | }} 80 | onKeyUp={(e) => { 81 | if ((e.key === 'Enter' || e.key === ' ') && mousedown) { 82 | isMouseDown(false) 83 | } 84 | }} 85 | onMouseDown={(e) => { 86 | if (e.button === 0) { 87 | toggle() 88 | } 89 | }} 90 | onTouchCancel={() => { 91 | isMouseDown(false) 92 | }} 93 | onTouchEnd={() => { 94 | isMouseDown(false) 95 | }} 96 | > 97 |
98 | {createElement(!inactive && playing ? PauseButtonSVG : PlayButtonSVG, { 99 | tabIndex: -1, 100 | })} 101 |
102 |
103 | ) 104 | } 105 | 106 | const PlaybarSlider: FC<{ 107 | ariaLabel: string 108 | inactive: boolean 109 | setValue: number 110 | width: number 111 | onChange: (x: number) => void 112 | }> = ({ ariaLabel, inactive, setValue, width, onChange }): JSX.Element => { 113 | /* 114 | The slider element of the playbar. 115 | */ 116 | const fidelity = 10000 117 | const self = useRef(null) 118 | // mousedown state 119 | const [mousedown, isMouseDown] = useState(false) 120 | // slider value - state and prop 121 | const [value, updateValue] = useState(inactive ? 0 : Math.max(Math.min(setValue, 1), 0) * fidelity) 122 | useEffect(() => { 123 | updateValue(inactive ? 0 : Math.max(Math.min(setValue, 1), 0) * fidelity) 124 | }, [inactive, setValue]) 125 | // dynamic width - state and prop 126 | // this maintains that the svg is always positioned within the slider 127 | const [stateWidth, updateWidth] = useState(width) 128 | useEffect(() => { 129 | const computeWidth = () => { 130 | if (self.current?.parentElement) { 131 | updateWidth(Math.min(width, self.current.parentElement.getBoundingClientRect().width)) 132 | } 133 | } 134 | window.addEventListener('resize', computeWidth) 135 | computeWidth() 136 | return () => { 137 | window.removeEventListener('resize', computeWidth) 138 | } 139 | }, [width]) 140 | // this useEffect adds a global mouse up to allow for press and hover, 141 | useEffect(() => { 142 | const mouseup = (): void => { 143 | isMouseDown(false) 144 | } 145 | window.addEventListener('mouseup', mouseup) 146 | return () => { 147 | window.removeEventListener('mouseup', mouseup) 148 | } 149 | }, []) 150 | // onChange event with prop 151 | const changeSlider = (v: number) => { 152 | updateValue(v) 153 | onChange(v / fidelity) 154 | } 155 | 156 | return ( 157 | <> 158 |
159 | 169 | 170 | 176 | 183 | 184 | 185 |
{ 196 | switch (e.key) { 197 | case 'Up': 198 | case 'ArrowUp': 199 | case 'Right': 200 | case 'ArrowRight': { 201 | e.preventDefault() 202 | changeSlider(Math.min(Math.round(value + fidelity / 100), fidelity)) 203 | break 204 | } 205 | case 'Down': 206 | case 'ArrowDown': 207 | case 'Left': 208 | case 'ArrowLeft': 209 | { 210 | e.preventDefault() 211 | changeSlider(Math.max(Math.round(value - fidelity / 100), 0)) 212 | } 213 | break 214 | case 'PageUp': { 215 | e.preventDefault() 216 | changeSlider(Math.min(Math.round(value + fidelity / 10), fidelity)) 217 | break 218 | } 219 | case 'PageDown': { 220 | e.preventDefault() 221 | changeSlider(Math.max(Math.round(value - fidelity / 10), 0)) 222 | break 223 | } 224 | default: 225 | break 226 | } 227 | }} 228 | > 229 |
230 | { 243 | changeSlider(+e.target.value) 244 | }} 245 | onMouseDown={() => { 246 | isMouseDown(true) 247 | }} 248 | onTouchCancel={() => { 249 | isMouseDown(false) 250 | }} 251 | onTouchEnd={() => { 252 | isMouseDown(false) 253 | }} 254 | onTouchMove={(e) => { 255 | if (self.current && e.targetTouches[0]) { 256 | const rect = self.current.getBoundingClientRect() 257 | changeSlider( 258 | Math.max( 259 | Math.min( 260 | Math.round( 261 | (e.targetTouches[0].clientX - (rect.x + 5)) / ((rect.width - 10) / fidelity), 262 | ), 263 | fidelity, 264 | ), 265 | 0, 266 | ), 267 | ) 268 | } 269 | }} 270 | onTouchStart={() => { 271 | isMouseDown(true) 272 | }} 273 | /> 274 |
275 |
276 | 277 | ) 278 | } 279 | 280 | const Playbar: FC<{ 281 | ariaLabel?: string 282 | inactive?: boolean 283 | setPlaying?: boolean 284 | setValue?: number 285 | width?: number 286 | onChange?: (x: number) => void 287 | onPlay?: (b: boolean) => void 288 | }> = ({ 289 | ariaLabel = 'playbar', 290 | inactive = false, 291 | setPlaying = false, 292 | setValue = 0, 293 | width = 200, 294 | onChange = () => { 295 | /**/ 296 | }, 297 | onPlay = () => { 298 | /**/ 299 | }, 300 | }): JSX.Element => { 301 | /* 302 | [playbar] 303 | */ 304 | 305 | width = Math.max(width, 100) 306 | 307 | return ( 308 |
309 | 310 | 311 |
312 | ) 313 | } 314 | 315 | export default Playbar 316 | -------------------------------------------------------------------------------- /src/components/radiogroup.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/radiogroup.module.scss' 6 | import RadioGroupSVG from '../svg/radiogroup-row.svg?react' 7 | 8 | const RadioGroup: FC<{ 9 | ariaLabel?: string 10 | items?: string[] 11 | spacing?: number 12 | setValue?: number 13 | onClick?: (i: number) => void 14 | }> = ({ 15 | ariaLabel = 'radiogroup', 16 | items = ['', ''], 17 | spacing = 20, 18 | setValue = 0, 19 | onClick = () => { 20 | /**/ 21 | }, 22 | }) => { 23 | /* 24 | [radiogroup] 25 | */ 26 | 27 | const self = useRef(null) 28 | // which toggle is pressed - state and prop 29 | const [index, indexPressed] = useState(setValue) 30 | useEffect(() => { 31 | indexPressed(setValue) 32 | }, [setValue]) 33 | // click event with prop 34 | const togglePressed = (i: number): void => { 35 | i = i === index ? 0 : i 36 | indexPressed(i) 37 | onClick(i) 38 | } 39 | // keyboard event specific watch state 40 | const [keydown, isKeyDown] = useState(false) 41 | // which toggle is focused 42 | const [focus, indexFocused] = useState(setValue) 43 | // focus method 44 | const toggleFocused = (i: number) => { 45 | indexFocused(i) 46 | ;(self.current?.childNodes[i] as HTMLElement).focus() 47 | } 48 | // this useEffect adds a touch event listener used to prevent bubbling. 49 | useEffect(() => { 50 | const touchstart = (e: TouchEvent): void => { 51 | if (e.cancelable && self.current && e.targetTouches[0]) { 52 | e.preventDefault() 53 | let t: number | null = null 54 | for (let i = 0; i < self.current.childNodes.length; i++) { 55 | const b = (self.current.childNodes[i] as HTMLElement).getBoundingClientRect() 56 | if (e.targetTouches[0].clientY > b.top && e.targetTouches[0].clientY < b.bottom) { 57 | t = i + 1 58 | break 59 | } 60 | } 61 | if (t !== null) { 62 | togglePressed(t) 63 | } 64 | } 65 | } 66 | self.current?.addEventListener('touchstart', touchstart) 67 | const cleanup_self = self.current 68 | return () => { 69 | cleanup_self?.removeEventListener('touchstart', touchstart) 70 | } 71 | }) 72 | 73 | return ( 74 |
75 | {items.map((item: string, i: number) => { 76 | i++ 77 | return ( 78 |
16 ? `${spacing.toString()}px` : '16px' }} 84 | tabIndex={i === Math.max(index, 1) ? 0 : -1} 85 | onFocus={() => { 86 | indexFocused(i - 1) 87 | }} 88 | onKeyDown={(e) => { 89 | switch (e.key) { 90 | case 'Enter': 91 | case ' ': { 92 | e.preventDefault() 93 | if (!keydown) { 94 | isKeyDown(true) 95 | togglePressed(i) 96 | } 97 | break 98 | } 99 | case 'Up': 100 | case 'ArrowUp': { 101 | e.preventDefault() 102 | toggleFocused(focus < 1 ? items.length - 1 : focus - 1) 103 | break 104 | } 105 | case 'Down': 106 | case 'ArrowDown': { 107 | e.preventDefault() 108 | toggleFocused((focus + 1) % items.length) 109 | break 110 | } 111 | default: 112 | break 113 | } 114 | }} 115 | onKeyUp={(e) => { 116 | if ((e.key === 'Enter' || e.key === ' ') && keydown) { 117 | e.preventDefault() 118 | isKeyDown(false) 119 | } 120 | }} 121 | onMouseDown={(e) => { 122 | if (e.button === 0) { 123 | togglePressed(i) 124 | } 125 | }} 126 | > 127 | {item && ( 128 |

16 ? `${spacing.toString()}px` : '16px', 131 | paddingRight: item ? '10px' : 0, 132 | }} 133 | tabIndex={-1} 134 | > 135 | {item} 136 |

137 | )} 138 |
147 | 148 |
149 |
150 | ) 151 | })} 152 |
153 | ) 154 | } 155 | 156 | export default RadioGroup 157 | -------------------------------------------------------------------------------- /src/components/slider.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, type TouchEvent as ReactTouchEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/slider.module.scss' 6 | 7 | const Slider: FC<{ 8 | ariaLabel?: string 9 | setValue?: number 10 | width?: number 11 | onChange?: (x: number) => void 12 | }> = ({ 13 | ariaLabel = 'slider', 14 | setValue = 0, 15 | width = 200, 16 | onChange = () => { 17 | /**/ 18 | }, 19 | }) => { 20 | /* 21 | [slider] 22 | */ 23 | 24 | const fidelity = 10000 25 | const self = useRef(null) 26 | 27 | // decalre slider colors 28 | const SliderColors = useMemo<{ 29 | positive: string 30 | negative: string 31 | off: string 32 | }>(() => { 33 | return { 34 | positive: '#cee5e8', 35 | negative: '#595959', 36 | off: '#818d8f', 37 | } 38 | }, []) 39 | 40 | // declare slider colour update function 41 | const colourAndState = useCallback( 42 | (new_value: number): void => { 43 | if (new_value !== 0) { 44 | if (self.current) { 45 | const sliderWidth = self.current.getBoundingClientRect().width - 10 46 | const position = ((sliderWidth - 6) * new_value) / fidelity 47 | setBackground(`linear-gradient(90deg, 48 | ${SliderColors.negative}, 49 | ${SliderColors.negative} 0px, 50 | ${SliderColors.positive} 0px, 51 | ${SliderColors.positive} ${(position - 1).toString()}px, 52 | ${SliderColors.negative} ${(position - 1).toString()}px, 53 | ${SliderColors.negative} ${position.toString()}px, 54 | ${SliderColors.positive} ${position.toString()}px, 55 | ${SliderColors.positive} ${(position + 6).toString()}px, 56 | ${SliderColors.negative} ${(position + 6).toString()}px 57 | )`) 58 | updateValue(new_value) 59 | } 60 | } else { 61 | setBackground( 62 | `linear-gradient(90deg, ${SliderColors.off}, ${SliderColors.off} 6px, ${SliderColors.negative} 6px)`, 63 | ) 64 | updateValue(0) 65 | } 66 | }, 67 | [SliderColors], 68 | ) 69 | 70 | // what is the value - state and prop 71 | const [value, updateValue] = useState(Math.max(Math.min(setValue, 1), 0) * fidelity) 72 | useEffect(() => { 73 | colourAndState(Math.max(Math.min(setValue, 1), 0) * fidelity) 74 | }, [setValue, colourAndState]) 75 | 76 | // background gradient / colour 77 | const [background, setBackground] = useState( 78 | `linear-gradient(90deg, ${SliderColors.off}, ${SliderColors.off} 6px, ${SliderColors.negative} 6px)`, 79 | ) 80 | 81 | // this useEffect adds a touch event listener used to prevent bubbling. 82 | useEffect(() => { 83 | const touchstart = (e: TouchEvent) => { 84 | if (e.cancelable) { 85 | e.preventDefault() 86 | touchmove(e) 87 | } 88 | } 89 | self.current?.addEventListener('touchstart', touchstart) 90 | const cleanup_self = self.current 91 | return () => { 92 | cleanup_self?.removeEventListener('touchstart', touchstart) 93 | } 94 | }) 95 | 96 | const touchmove = (e: ReactTouchEvent | TouchEvent) => { 97 | if (self.current && e.targetTouches[0]) { 98 | const rect = self.current.getBoundingClientRect() 99 | const new_val = Math.max( 100 | Math.min(Math.round((e.targetTouches[0].clientX - (rect.x + 5)) / ((rect.width - 10) / fidelity)), fidelity), 101 | 0, 102 | ) 103 | colourAndState(new_val) 104 | onChange(new_val / fidelity) 105 | } 106 | } 107 | 108 | return ( 109 |
{ 120 | let new_val: number 121 | switch (e.key) { 122 | case 'Up': 123 | case 'ArrowUp': 124 | case 'Right': 125 | case 'ArrowRight': { 126 | e.preventDefault() 127 | new_val = Math.min(Math.round(value + fidelity / 100), fidelity) 128 | colourAndState(new_val) 129 | onChange(new_val / fidelity) 130 | break 131 | } 132 | case 'Down': 133 | case 'ArrowDown': 134 | case 'Left': 135 | case 'ArrowLeft': { 136 | e.preventDefault() 137 | new_val = Math.max(Math.round(value - fidelity / 100), 0) 138 | colourAndState(new_val) 139 | onChange(new_val / fidelity) 140 | break 141 | } 142 | case 'PageUp': { 143 | e.preventDefault() 144 | new_val = Math.min(Math.round(value + fidelity / 10), fidelity) 145 | colourAndState(new_val) 146 | onChange(new_val / fidelity) 147 | break 148 | } 149 | case 'PageDown': { 150 | e.preventDefault() 151 | new_val = Math.max(Math.round(value - fidelity / 10), 0) 152 | colourAndState(new_val) 153 | onChange(new_val / fidelity) 154 | break 155 | } 156 | default: 157 | break 158 | } 159 | }} 160 | > 161 |
162 | { 175 | const new_val = +e.target.value 176 | colourAndState(new_val) 177 | onChange(new_val / fidelity) 178 | }} 179 | onTouchMove={(e) => { 180 | touchmove(e) 181 | }} 182 | /> 183 |
184 |
185 | ) 186 | } 187 | 188 | export default Slider 189 | -------------------------------------------------------------------------------- /src/components/textbutton.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/textbutton.module.scss' 6 | 7 | const TextButton: FC<{ 8 | ariaLabel?: string 9 | ariaPressed?: boolean | null 10 | inactive?: boolean 11 | mode?: boolean 12 | setValue?: boolean 13 | text?: string 14 | toggleText?: string 15 | onChange?: (b: boolean) => void 16 | onClick?: () => void 17 | }> = ({ 18 | ariaLabel = 'textbutton', 19 | ariaPressed = null, 20 | inactive = false, 21 | mode = false, 22 | setValue = false, 23 | text = 'Button', 24 | toggleText = 'Button On', 25 | onChange = () => { 26 | /**/ 27 | }, 28 | onClick = () => { 29 | /**/ 30 | }, 31 | }) => { 32 | /* 33 | [textbutton] 34 | */ 35 | 36 | const self = useRef(null) 37 | 38 | // declare button colours 39 | const TextColours: { 40 | clicked: string 41 | inactive: string 42 | neutral: string 43 | toggleOff: string 44 | toggleOffClicked: string 45 | toggleOffInactive: string 46 | } = { 47 | clicked: '#96aaac', 48 | inactive: '#afbabb', 49 | neutral: '#cee5e8', 50 | toggleOff: '#808080', 51 | toggleOffClicked: '#6c6c6c', 52 | toggleOffInactive: '#878787', 53 | } 54 | const BackgroundGradients: { 55 | neutral: string 56 | hover: string 57 | inactive: string 58 | } = { 59 | neutral: 'linear-gradient(to top, rgb(51, 51, 51) 0%, rgb(76, 76, 76) 100%)', 60 | hover: 'linear-gradient(to top, rgb(51, 51, 51) 0%, rgb(81, 81, 81) 100%)', 61 | inactive: 'linear-gradient(to top, rgb(138, 138, 138) 0%, rgb(151, 151, 151) 100%)', 62 | } 63 | 64 | // is the toggle pressed - state and prop 65 | const [pressed, isPressed] = useState(mode && setValue) 66 | useEffect(() => { 67 | isPressed(mode && setValue) 68 | }, [mode, setValue]) 69 | // button interactions 70 | const [hover, setHover] = useState(false) 71 | const [mousedown, setMousedown] = useState(false) 72 | const [externalMousedown, setExternalMousedown] = useState(false) 73 | 74 | const pressButton = (new_value: boolean): void => { 75 | // press button 76 | if (!inactive) { 77 | isPressed(new_value) 78 | onClick() 79 | if (mode) { 80 | onChange(new_value) 81 | } 82 | } 83 | } 84 | 85 | // this useEffect adds a touch event listener used to prevent bubbling. 86 | // also add global mouse up and down listeners for cosmetic updates. 87 | useEffect(() => { 88 | const globalMousedown = (e: MouseEvent) => { 89 | if (!inactive && self.current) { 90 | const rect = self.current.getBoundingClientRect() 91 | if (e.clientX > rect.left && e.clientX < rect.right && e.clientY > rect.top && e.clientY < rect.bottom) { 92 | setMousedown(true) 93 | } else { 94 | setExternalMousedown(true) 95 | } 96 | } 97 | } 98 | const globalMouseup = () => { 99 | setExternalMousedown(false) 100 | setMousedown(false) 101 | } 102 | const touchstart = (e: TouchEvent): void => { 103 | if (e.cancelable) { 104 | e.preventDefault() 105 | setHover(true) 106 | setMousedown(true) 107 | } 108 | } 109 | if (self.current) { 110 | window.addEventListener('mousedown', globalMousedown) 111 | window.addEventListener('mouseup', globalMouseup) 112 | self.current.addEventListener('touchstart', touchstart) 113 | } 114 | const cleanup_self = self.current 115 | return () => { 116 | if (cleanup_self) { 117 | window.removeEventListener('mousedown', globalMousedown) 118 | window.removeEventListener('mouseup', globalMouseup) 119 | cleanup_self.removeEventListener('touchstart', touchstart) 120 | } 121 | } 122 | }) 123 | 124 | return ( 125 |
{ 139 | if (e.key === 'Enter' || e.key === ' ') { 140 | e.preventDefault() 141 | if (!mousedown) { 142 | setHover(true) 143 | setMousedown(true) 144 | } 145 | } 146 | }, 147 | onKeyUp: (e) => { 148 | if ((e.key === 'Enter' || e.key === ' ') && mousedown) { 149 | e.preventDefault() 150 | setHover(false) 151 | setMousedown(false) 152 | pressButton(mode && !pressed) 153 | } 154 | }, 155 | })} 156 | className={style.textbutton} 157 | ref={self} 158 | style={{ 159 | background: inactive 160 | ? BackgroundGradients.inactive 161 | : hover && !externalMousedown 162 | ? BackgroundGradients.hover 163 | : BackgroundGradients.neutral, 164 | }} 165 | onClick={() => { 166 | pressButton(mode && !pressed) 167 | }} 168 | onMouseEnter={() => { 169 | if (!inactive) { 170 | setHover(true) 171 | } 172 | }} 173 | onMouseLeave={() => { 174 | if (!inactive) { 175 | setHover(false) 176 | } 177 | }} 178 | onTouchEnd={(e) => { 179 | e.preventDefault() 180 | setHover(false) 181 | setMousedown(false) 182 | pressButton(mode && !pressed) 183 | }} 184 | onTouchCancel={() => { 185 | setHover(false) 186 | setMousedown(false) 187 | }} 188 | > 189 |

211 | {mode ? (pressed ? (mousedown && hover ? text : toggleText) : mousedown && hover ? toggleText : text) : text} 212 |

213 |
214 | ) 215 | } 216 | 217 | export default TextButton 218 | -------------------------------------------------------------------------------- /src/components/toggle.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/toggle.module.scss' 6 | import SVG from '../svg/toggle.svg?react' 7 | 8 | const Toggle: FC<{ 9 | ariaLabel?: string 10 | setValue?: boolean 11 | onClick?: (b: boolean) => void 12 | }> = ({ 13 | ariaLabel = 'toggle', 14 | setValue = false, 15 | onClick = () => { 16 | /**/ 17 | }, 18 | }) => { 19 | /* 20 | [toggle] 21 | */ 22 | 23 | const self = useRef(null) 24 | // is the toggle pressed - state and prop 25 | const [pressed, isPressed] = useState(setValue) 26 | useEffect(() => { 27 | isPressed(setValue) 28 | }, [setValue]) 29 | // click event with prop 30 | const togglePressed = (): void => { 31 | isPressed(!pressed) 32 | onClick(pressed) 33 | } 34 | // keyboard event specific watch state 35 | const [keydown, isKeyDown] = useState(false) 36 | // this useEffect adds a touch event listener used to prevent bubbling. 37 | useEffect(() => { 38 | const touchstart = (e: TouchEvent): void => { 39 | if (e.cancelable) { 40 | e.preventDefault() 41 | togglePressed() 42 | } 43 | } 44 | self.current?.addEventListener('touchstart', touchstart) 45 | const cleanup_self = self.current 46 | return () => { 47 | cleanup_self?.removeEventListener('touchstart', touchstart) 48 | } 49 | }) 50 | 51 | return ( 52 |
{ 60 | if (e.key === 'Enter' || e.key === ' ') { 61 | e.preventDefault() 62 | if (!keydown) { 63 | isKeyDown(true) 64 | togglePressed() 65 | } 66 | } 67 | }} 68 | onKeyUp={(e) => { 69 | if ((e.key === 'Enter' || e.key === ' ') && keydown) { 70 | e.preventDefault() 71 | isKeyDown(false) 72 | } 73 | }} 74 | onMouseDown={(e) => { 75 | if (e.button === 0) { 76 | togglePressed() 77 | } 78 | }} 79 | > 80 | 86 |
87 | ) 88 | } 89 | 90 | export default Toggle 91 | -------------------------------------------------------------------------------- /src/components/umenu.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import { type FC, useEffect, useRef, useState } from 'react' 3 | 4 | // src 5 | import style from '../scss/umenu.module.scss' 6 | import UmenuSVG from '../svg/umenu-arrow.svg?react' 7 | 8 | const Umenu: FC<{ 9 | ariaLabel?: string 10 | items?: string[] 11 | width?: number 12 | setValue?: number 13 | onChange?: (i: number) => void 14 | }> = ({ 15 | ariaLabel = 'umenu', 16 | items = [], 17 | width = 100, 18 | setValue = 0, 19 | onChange = () => { 20 | /**/ 21 | }, 22 | }) => { 23 | /* 24 | [umenu] 25 | */ 26 | 27 | const self = useRef(null) 28 | // which toggle is pressed - state and prop 29 | const [index, indexPressed] = useState(setValue < items.length && setValue >= 0 ? setValue : 0) 30 | useEffect(() => { 31 | indexPressed((i) => (setValue < items.length && setValue >= 0 ? setValue : i)) 32 | }, [items, setValue]) 33 | // is the dropdown displayed 34 | const [dropdownVisible, setDropdown] = useState(false) 35 | const [dropdownWidth, setDropdownWidth] = useState('fit-content') 36 | // which index is focused 37 | const [focus, setFocus] = useState(null) 38 | // keyboard event specific watch state 39 | const [keydown, isKeyDown] = useState(false) 40 | 41 | // this useEffect adds a touch event listener used to prevent bubbling. 42 | useEffect(() => { 43 | const listTouchStart = (e: TouchEvent) => { 44 | if (e.cancelable && self.current) { 45 | let t: number | null = null 46 | self.current.childNodes[1]?.childNodes.forEach((button, i) => { 47 | const b = (button as HTMLElement).getBoundingClientRect() 48 | if (e.targetTouches[0] && e.targetTouches[0].clientY > b.top && e.targetTouches[0].clientY < b.bottom) { 49 | t = i 50 | } 51 | }) 52 | setFocus(t) 53 | } 54 | } 55 | const toggleTouchStart = (e: TouchEvent): void => { 56 | if (e.cancelable) { 57 | e.preventDefault() 58 | openDropdown(null) 59 | } 60 | } 61 | 62 | const isNotInViewport = (): void => { 63 | if (dropdownVisible && self.current) { 64 | const umenuDim = self.current.getBoundingClientRect() 65 | const dropdownDim = (self.current.childNodes[1] as HTMLElement).getBoundingClientRect() 66 | if ( 67 | umenuDim.top > window.innerHeight || 68 | dropdownDim.bottom < 0 || 69 | umenuDim.left > window.innerWidth || 70 | Math.max(umenuDim.right, dropdownDim.right) < 0 71 | ) { 72 | setDropdown(false) 73 | setFocus(null) 74 | } 75 | } 76 | } 77 | 78 | const customBlur = (e: MouseEvent): void => { 79 | if (self.current) { 80 | const umenuDim = self.current.getBoundingClientRect() 81 | const dropdownDim = (self.current.childNodes[1] as HTMLElement).getBoundingClientRect() 82 | if ( 83 | e.clientX > umenuDim.left && 84 | e.clientX < umenuDim.right && 85 | e.clientY > umenuDim.top && 86 | e.clientY < umenuDim.bottom 87 | ) { 88 | return 89 | } 90 | if (dropdownVisible) { 91 | if ( 92 | e.clientX > dropdownDim.left && 93 | e.clientX < dropdownDim.right && 94 | e.clientY > dropdownDim.top && 95 | e.clientY < dropdownDim.bottom 96 | ) { 97 | return 98 | } 99 | setDropdown(false) 100 | setFocus(null) 101 | } 102 | self.current.blur() 103 | } 104 | } 105 | 106 | if (self.current) { 107 | ;(self.current.childNodes[0] as HTMLElement).addEventListener('touchstart', toggleTouchStart) 108 | ;(self.current.childNodes[1] as HTMLElement).addEventListener('touchstart', listTouchStart) 109 | window.addEventListener('resize', responsiveDropdown) 110 | window.addEventListener('mousedown', customBlur) 111 | window.addEventListener('scroll', isNotInViewport) 112 | } 113 | const cleanup_self = self.current 114 | return () => { 115 | if (cleanup_self) { 116 | ;(cleanup_self.childNodes[0] as HTMLElement).removeEventListener('touchstart', toggleTouchStart) 117 | ;(cleanup_self.childNodes[1] as HTMLElement).removeEventListener('touchstart', listTouchStart) 118 | window.removeEventListener('mousedown', customBlur) 119 | window.removeEventListener('resize', responsiveDropdown) 120 | window.removeEventListener('scroll', isNotInViewport) 121 | } 122 | } 123 | }) 124 | 125 | const openDropdown = (focus: number | null): void => { 126 | setDropdown(!dropdownVisible) 127 | setFocus(focus) 128 | if (dropdownVisible) { 129 | responsiveDropdown() 130 | if (focus !== null) { 131 | ;(self.current?.childNodes[1]?.childNodes[focus] as HTMLElement).focus() 132 | } 133 | } 134 | } 135 | 136 | const responsiveDropdown = (): void => { 137 | if (self.current) { 138 | const maxWidth = 139 | (self.current.parentNode as HTMLElement).getBoundingClientRect().right - 140 | self.current.getBoundingClientRect().left 141 | setDropdownWidth('fit-content') 142 | if (maxWidth < (self.current.childNodes[1] as HTMLElement).offsetWidth) { 143 | setDropdownWidth(`${(maxWidth - 2).toString()}px`) 144 | } 145 | } 146 | } 147 | 148 | const arrowKeys = (value: 1 | -1): void => { 149 | if (focus !== null) { 150 | setFocus((focus + items.length + value) % items.length) 151 | ;(self.current?.childNodes[1]?.childNodes[focus] as HTMLElement).focus() 152 | } else { 153 | const f = value === -1 ? items.length - 1 : 0 154 | setFocus(f) 155 | ;(self.current?.childNodes[1]?.childNodes[f] as HTMLElement).focus() 156 | } 157 | } 158 | 159 | const changeSelected = (new_index: number, aria: boolean): void => { 160 | indexPressed(new_index) 161 | setDropdown(false) 162 | onChange(new_index) 163 | if (aria) { 164 | self.current?.focus() 165 | } 166 | } 167 | 168 | return ( 169 |
0 && { 174 | 'aria-expanded': dropdownVisible, 175 | 'aria-haspopup': 'listbox', 176 | 'aria-label': `${ariaLabel}: ${items[index] ?? 'nothing'} selected`, 177 | role: 'button', 178 | tabIndex: 0, 179 | onKeyDown: (e) => { 180 | switch (e.key) { 181 | case 'Enter': 182 | case ' ': { 183 | e.preventDefault() 184 | if (!keydown) { 185 | isKeyDown(true) 186 | if (dropdownVisible) { 187 | if (focus !== null) { 188 | changeSelected(focus, true) 189 | } else { 190 | openDropdown(null) 191 | } 192 | } else { 193 | openDropdown(0) 194 | } 195 | } 196 | break 197 | } 198 | case 'Esc': 199 | case 'Escape': { 200 | e.preventDefault() 201 | setDropdown(false) 202 | setFocus(null) 203 | self.current?.focus() 204 | break 205 | } 206 | case 'Home': 207 | case 'End': { 208 | e.preventDefault() 209 | setFocus(e.key === 'Home' ? 0 : items.length - 1) 210 | if (focus !== null) { 211 | ;(self.current?.childNodes[1]?.childNodes[focus] as HTMLElement).focus() 212 | } 213 | break 214 | } 215 | case 'Up': 216 | case 'ArrowUp': { 217 | e.preventDefault() 218 | if (dropdownVisible) { 219 | arrowKeys(-1) 220 | } 221 | break 222 | } 223 | case 'Down': 224 | case 'ArrowDown': { 225 | e.preventDefault() 226 | if (dropdownVisible) { 227 | arrowKeys(1) 228 | } 229 | break 230 | } 231 | case 'Tab': { 232 | if (dropdownVisible) { 233 | setDropdown(false) 234 | } 235 | break 236 | } 237 | default: 238 | break 239 | } 240 | }, 241 | onKeyUp: (e) => { 242 | if ((e.key === 'Enter' || e.key === ' ') && keydown) { 243 | e.preventDefault() 244 | isKeyDown(false) 245 | } 246 | }, 247 | })} 248 | > 249 |
{ 252 | if (e.button === 0) { 253 | openDropdown(null) 254 | } 255 | }} 256 | > 257 |

{items[index]}

258 | 259 |
260 |
    0 ? 'block' : 'none', 266 | width: dropdownWidth || 'fit-content', 267 | whiteSpace: dropdownWidth !== 'fit-content' ? 'normal' : 'nowrap', 268 | }} 269 | > 270 | {items.map((item, i) => { 271 | return ( 272 |
  • { 281 | setFocus(i) 282 | }} 283 | onMouseLeave={() => { 284 | setFocus(null) 285 | }} 286 | onClick={() => { 287 | changeSelected(i, false) 288 | }} 289 | onTouchEnd={() => { 290 | setFocus(null) 291 | }} 292 | > 293 | {item} 294 |
  • 295 | ) 296 | })} 297 |
298 |
299 | ) 300 | } 301 | 302 | export default Umenu 303 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bang } from './components/bang.tsx' 2 | export { default as Ezdac } from './components/ezdac.tsx' 3 | export { default as Message } from './components/message.tsx' 4 | export { default as Object } from './components/object.tsx' 5 | export { default as Playbar } from './components/playbar.tsx' 6 | export { default as RadioGroup } from './components/radiogroup.tsx' 7 | export { default as Slider } from './components/slider.tsx' 8 | export { default as TextButton } from './components/textbutton.tsx' 9 | export { default as Toggle } from './components/toggle.tsx' 10 | export { default as Umenu } from './components/umenu.tsx' 11 | -------------------------------------------------------------------------------- /src/scss/bang.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .bang { 4 | @include utils.main; 5 | height: 24px; 6 | width: 24px; 7 | 8 | @include utils.focus; 9 | 10 | svg { 11 | outline: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/scss/ezdac.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .ezdac { 4 | @include utils.main; 5 | height: 45px; 6 | width: 45px; 7 | 8 | @include utils.focus; 9 | 10 | svg { 11 | outline: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/scss/message.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .message { 4 | @include utils.main; 5 | height: fit-content; 6 | min-height: 24px; 7 | width: fit-content; 8 | min-width: 29.5px; 9 | background: linear-gradient(to top, rgb(51 51 51) 0%, rgb(76 76 76) 100%); 10 | border-radius: 5px; 11 | 12 | @include utils.focus; 13 | 14 | p { 15 | margin: auto 0; 16 | line-height: 14px; 17 | font-size: 13px; 18 | color: #f7f7f7; 19 | outline: 0; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/scss/object.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .object { 4 | @include utils.main; 5 | height: fit-content; 6 | min-height: 24px; 7 | width: fit-content; 8 | min-width: 29.5px; 9 | max-width: 100%; 10 | 11 | svg { 12 | height: 24px; 13 | margin: 0; 14 | position: absolute; 15 | top: 0; 16 | left: 5px; 17 | } 18 | 19 | p { 20 | height: fit-content; 21 | min-height: 14px; 22 | min-width: 19.5px; 23 | margin: 4px 0; 24 | padding: 1px 5px; 25 | line-height: 14px; 26 | font-size: 13px; 27 | color: #f7f7f7; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/scss/playbar.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | @mixin thumb-style { 4 | height: 20px; 5 | width: 12px; 6 | appearance: none; 7 | background: none; 8 | border: 0; 9 | opacity: 0; 10 | } 11 | 12 | .playbar { 13 | @include utils.main; 14 | height: 20px; 15 | // width set by component 16 | max-width: 100%; 17 | background: #333; 18 | 19 | hr { 20 | height: 2px; 21 | width: calc(100% - 37px); 22 | margin: auto; 23 | position: absolute; 24 | top: 0; 25 | bottom: 0; 26 | left: 26px; 27 | background: #808080; 28 | border: 0; 29 | } 30 | 31 | > svg { 32 | height: 20px; 33 | width: 20px; 34 | margin: auto; 35 | position: absolute; 36 | top: 0; 37 | bottom: 0; 38 | pointer-events: none; 39 | } 40 | 41 | > div:first-of-type { 42 | height: 20px; 43 | width: 20px; 44 | position: absolute; 45 | top: 0; 46 | left: 0; 47 | margin: 0; 48 | 49 | @include utils.focus; 50 | 51 | > div { 52 | height: inherit; 53 | width: inherit; 54 | outline: 0; 55 | 56 | svg { 57 | height: inherit; 58 | width: inherit; 59 | pointer-events: none; 60 | } 61 | } 62 | } 63 | 64 | > div:last-of-type { 65 | z-index: 10; 66 | height: 20px; 67 | margin: 0; 68 | width: calc(100% - 25px); 69 | padding: 0 5px 0 0; 70 | float: right; 71 | 72 | @include utils.focus; 73 | 74 | div { 75 | height: inherit; 76 | width: 100%; 77 | position: relative; 78 | padding: 0; 79 | outline: 0; 80 | 81 | input { 82 | height: 100%; 83 | width: 100%; 84 | margin: 0; 85 | appearance: none; 86 | background: inherit; 87 | outline: 0; 88 | 89 | &::-webkit-slider-thumb { 90 | @include thumb-style; 91 | } 92 | 93 | &::-moz-range-thumb { 94 | @include thumb-style; 95 | } 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/scss/radiogroup.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .radiogroup { 4 | @include utils.main; 5 | height: fit-content; 6 | width: fit-content; 7 | max-width: 100%; 8 | background: unset; 9 | 10 | @include utils.focus; 11 | 12 | > div { 13 | height: fit-content; 14 | width: fit-content; 15 | display: flex; 16 | float: right; 17 | clear: right; 18 | 19 | @include utils.focus; 20 | 21 | p { 22 | height: inherit; 23 | width: fit-content; 24 | margin: 0; 25 | float: right; 26 | text-align: right; 27 | vertical-align: middle; 28 | white-space: nowrap; 29 | font-size: 16px; 30 | font-weight: bold; 31 | color: black; 32 | outline: 0; 33 | } 34 | 35 | div { 36 | height: inherit; 37 | width: 20.1px; 38 | display: grid; 39 | position: relative; 40 | background: #333; 41 | outline: 0; 42 | 43 | svg { 44 | height: 20px; 45 | width: 20px; 46 | place-self: center; 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/scss/slider.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | @mixin thumb-style() { 4 | height: 20px; 5 | width: 6px; 6 | appearance: none; 7 | background: none; 8 | border: 0; 9 | opacity: 0; 10 | } 11 | 12 | .slider { 13 | @include utils.main; 14 | height: 24px; 15 | width: fit-content; 16 | max-width: 100%; 17 | background: #393939; 18 | 19 | @include utils.focus; 20 | 21 | div { 22 | height: inherit; 23 | outline: 0; 24 | 25 | input { 26 | height: inherit; 27 | // width set by component 28 | max-width: calc(100% - 10px); 29 | margin: 0 5px; 30 | appearance: none; 31 | border-radius: 0; 32 | outline: 0; 33 | 34 | &::-webkit-slider-thumb { 35 | @include thumb-style; 36 | } 37 | 38 | &::-moz-range-thumb { 39 | @include thumb-style; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/scss/textbutton.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .textbutton { 4 | @include utils.main; 5 | height: 24px; 6 | width: fit-content; 7 | 8 | @include utils.focus; 9 | 10 | p { 11 | height: 100%; 12 | width: fit-content; 13 | min-width: 80px; 14 | max-width: calc(100% - 20px); 15 | padding: 0 10px; 16 | margin: 0; 17 | overflow: hidden; 18 | line-height: 24px; 19 | font-size: 13.5px; 20 | text-align: center; 21 | text-overflow: ellipsis; 22 | white-space: nowrap; 23 | outline: 0; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/scss/toggle.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .toggle { 4 | @include utils.main; 5 | height: 24px; 6 | width: 24px; 7 | 8 | @include utils.focus; 9 | 10 | svg { 11 | outline: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/scss/umenu.module.scss: -------------------------------------------------------------------------------- 1 | @use './utils.scss' as utils; 2 | 3 | .umenu { 4 | @include utils.main; 5 | height: 24px; 6 | // width set by component 7 | max-width: 100%; 8 | min-width: 40px; 9 | text-align: left; 10 | font-size: 13px; 11 | color: #f7f7f7; 12 | background: linear-gradient(to top, rgb(51 51 51) 0%, rgb(76 76 76) 100%); 13 | 14 | @include utils.focus; 15 | 16 | > div { 17 | outline: 0; 18 | 19 | p { 20 | line-height: 24px; 21 | margin: 0; 22 | padding: 0 22px 0 8px; 23 | text-align: left; 24 | white-space: nowrap; 25 | text-overflow: ellipsis; 26 | overflow: hidden; 27 | } 28 | 29 | svg { 30 | height: 24px; 31 | position: absolute; 32 | top: 0; 33 | right: 5px; 34 | } 35 | } 36 | 37 | ul { 38 | height: fit-content; 39 | margin: 0; 40 | padding: 0; 41 | position: absolute; 42 | top: 24px; 43 | left: 0; 44 | z-index: 10; 45 | outline: 0; 46 | list-style: none; 47 | overflow: hidden; 48 | background: #393939; 49 | border: 1px solid black; 50 | border-radius: 4px; 51 | 52 | li { 53 | min-height: 13px; 54 | padding: 5px 20px; 55 | word-break: break-word; 56 | text-align: justify; 57 | outline: 0; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/scss/utils.scss: -------------------------------------------------------------------------------- 1 | @mixin main() { 2 | position: relative; 3 | font-family: Arial, sans-serif; 4 | text-size-adjust: 100%; 5 | word-break: break-word; 6 | touch-action: none; 7 | user-select: none; 8 | -webkit-user-select: none; 9 | -webkit-tap-highlight-color: transparent; 10 | cursor: default; 11 | } 12 | 13 | @mixin focus() { 14 | &:focus { 15 | outline: #b4b4b4 solid 3px; 16 | z-index: 10; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/svg/bang.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 17 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/svg/ezdac.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/svg/inlet.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/svg/playbar-pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/svg/playbar-play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/svg/playbar-slider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/svg/radiogroup-row.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/svg/toggle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/svg/umenu-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // dependencies 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // type checking 4 | "allowUnreachableCode": false, 5 | "allowUnusedLabels": false, 6 | "alwaysStrict": true, 7 | "exactOptionalPropertyTypes": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitAny": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noPropertyAccessFromIndexSignature": false, 14 | "noUncheckedIndexedAccess": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "strict": true, 18 | "strictBindCallApply": true, 19 | "strictFunctionTypes": true, 20 | "strictNullChecks": true, 21 | "strictPropertyInitialization": true, 22 | "useUnknownInCatchVariables": true, 23 | // modules 24 | "module": "ESNext", 25 | "moduleResolution": "node", 26 | "resolveJsonModule": true, 27 | // emit 28 | "noEmit": true, 29 | // javascript support 30 | "allowJs": false, 31 | // interop constraints 32 | "allowImportingTsExtensions": true, 33 | "allowSyntheticDefaultImports": true, 34 | "esModuleInterop": true, 35 | "forceConsistentCasingInFileNames": true, 36 | "isolatedModules": true, 37 | // language and environment 38 | "jsx": "react-jsx", 39 | "lib": ["dom", "dom.iterable", "ESNext"], 40 | "target": "ESNext", 41 | // completeness 42 | "skipLibCheck": true 43 | }, 44 | "include": ["src"], 45 | "references": [{ "path": "./tsconfig.node.json" }] 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "resolveJsonModule": true, 8 | "skipLibCheck": true, 9 | "strict": true 10 | }, 11 | "include": ["vite.config.ts", "package.json"] 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc' 2 | // biome-ignore lint/correctness/noNodejsModules: This script is ran by NodeJS. 3 | import { resolve } from 'node:path' 4 | import { defineConfig } from 'vite' 5 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' 6 | import dts from 'vite-plugin-dts' 7 | import svgr from 'vite-plugin-svgr' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | build: { 12 | copyPublicDir: false, 13 | lib: { 14 | entry: resolve(__dirname, 'src/index.ts'), 15 | formats: ['es'], 16 | }, 17 | minify: 'esbuild', 18 | rollupOptions: { 19 | external: ['react', 'react/jsx-runtime'], 20 | output: { entryFileNames: '[name].js', manualChunks: undefined }, 21 | }, 22 | target: 'ESNext', 23 | }, 24 | esbuild: { legalComments: 'none' }, 25 | plugins: [cssInjectedByJsPlugin(), dts({ include: ['src'] }), react(), svgr()], 26 | }) 27 | --------------------------------------------------------------------------------