├── .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 | [](https://www.npmjs.com/package/maxmsp-gui) [](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 |
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 |
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 |
25 |
--------------------------------------------------------------------------------
/src/svg/ezdac.svg:
--------------------------------------------------------------------------------
1 |
2 |
27 |
--------------------------------------------------------------------------------
/src/svg/inlet.svg:
--------------------------------------------------------------------------------
1 |
2 |
18 |
--------------------------------------------------------------------------------
/src/svg/playbar-pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/src/svg/playbar-play.svg:
--------------------------------------------------------------------------------
1 |
2 |
10 |
--------------------------------------------------------------------------------
/src/svg/playbar-slider.svg:
--------------------------------------------------------------------------------
1 |
2 |
15 |
--------------------------------------------------------------------------------
/src/svg/radiogroup-row.svg:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/src/svg/toggle.svg:
--------------------------------------------------------------------------------
1 |
2 |
14 |
--------------------------------------------------------------------------------
/src/svg/umenu-arrow.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
--------------------------------------------------------------------------------