├── .nvmrc ├── .gitignore ├── .npmignore ├── .github ├── FUNDING.yml └── workflows │ ├── verify.yml │ └── deploy.yml ├── .stylelintrc.json ├── src ├── build-settings.js ├── index.js ├── editable-object.html ├── webpack.dev.config.js ├── webpack.prod.config.js ├── build.js ├── editable-object.css └── editable-object.js ├── test ├── dev-server.js └── fixtures │ ├── index.html │ ├── no-data.html │ ├── add-property-placeholder.html │ ├── fluid-type.html │ ├── repeat-assign.html │ ├── handlers.html │ ├── disable-edit.html │ └── spinner.html ├── eslint.config.js ├── LICENSE.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | tmp 3 | test/fixtures/editable-object.js 4 | test/fixtures/modern-normalize.css -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.css 3 | *.js 4 | .github 5 | test 6 | src 7 | !dist/*.js 8 | !dist/*.css -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: localnerve 2 | custom: ['https://www.paypal.com/ncp/payment/DHCB5GUYMGX5U', 'https://www.paypal.com/donate/?hosted_button_id=U98LEKAK7DXML'] 3 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard" 4 | ], 5 | "plugins": [ 6 | "stylelint-no-unsupported-browser-features" 7 | ], 8 | "rules": { 9 | "at-rule-no-unknown": null, 10 | "declaration-property-value-no-unknown": null, 11 | "media-feature-range-notation": "prefix" 12 | }, 13 | "defaultSeverity": "error" 14 | } -------------------------------------------------------------------------------- /src/build-settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Build settings 3 | * 4 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 5 | * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms. 6 | */ 7 | import * as path from 'node:path'; 8 | import * as url from 'node:url'; 9 | 10 | export const thisDir = url.fileURLToPath(new URL('.', import.meta.url)); 11 | export const stageDir = path.join(thisDir, 'tmp'); 12 | export const distDir = path.join(thisDir, '..', 'dist'); -------------------------------------------------------------------------------- /test/dev-server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * test server. 3 | * 4 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 5 | * Copyrights licensed under the MIT License. See the accompanying LICENSE file for terms. 6 | */ 7 | import express from 'express'; 8 | 9 | const port = 3010; 10 | const app = express(); 11 | 12 | app.use('/', express.static('test/fixtures')); 13 | app.post('/shutdown', (req, res) => { 14 | res.sendStatus(200); 15 | process.exit(0); 16 | }); 17 | 18 | app.listen(port, err => { 19 | if (err) console.err(err); 20 | console.log(`Running on port ${port}`); 21 | }) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * editable-object Node entry point 3 | * 4 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 5 | * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms. 6 | */ 7 | import * as path from 'node:path'; 8 | import * as fs from 'node:fs/promises'; 9 | import * as url from 'node:url'; 10 | 11 | const thisDir = url.fileURLToPath(new URL('.', import.meta.url)); 12 | 13 | /** 14 | * Get the css file contents. 15 | * Useful for CSP builds. 16 | * 17 | * @returns {Promise} The utf8 css file content 18 | */ 19 | export function getEditableObjectCssText () { 20 | return fs.readFile(path.join(thisDir, 'editable-object.css'), { 21 | encoding: 'utf8' 22 | }); 23 | } -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | 6 | jobs: 7 | verify: 8 | 9 | runs-on: ubuntu-24.04 10 | 11 | strategy: 12 | matrix: 13 | node-version: [22.x, 24.x] 14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 15 | 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # 6.1.0 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npx browser-driver-manager install chrome 24 | - name: Run Lint and Test 25 | run: npm run lint && npm test -------------------------------------------------------------------------------- /src/editable-object.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 |
6 | 7 | 8 |
9 | 14 |
15 |
16 |
-------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: [ main ] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-24.04 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 6.0.1 14 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # 6.1.0 15 | with: 16 | node-version: '24.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - name: Update npm 19 | run: npm install -g npm@latest # ensure npm 11.5.1 or later 20 | - run: npm ci 21 | - name: Verify 22 | run: npm run lint 23 | - name: Publish 24 | run: npm publish --provenance --access public 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | 4 | export default [{ 5 | name: 'global', 6 | ignores: [ 7 | 'node_modules/**', 8 | 'test/fixtures/**', 9 | 'dist/**', 10 | 'tmp/**', 11 | 'src/tmp/**' 12 | ] 13 | }, { 14 | name: 'src-node', 15 | files: ['src/build*.js', 'src/index.js', 'webpack.prod.config.js'], 16 | languageOptions: { 17 | globals: { 18 | ...globals.node 19 | } 20 | }, 21 | rules: { 22 | ...js.configs.recommended.rules, 23 | indent: [2, 2, { 24 | SwitchCase: 1, 25 | MemberExpression: 1 26 | }], 27 | quotes: [2, 'single'], 28 | 'dot-notation': [2, {allowKeywords: true}] 29 | } 30 | }, { 31 | name: 'src-browser', 32 | files: ['src/editable-object.js'], 33 | languageOptions: { 34 | globals: { 35 | ...globals.browser 36 | } 37 | }, 38 | rules: { 39 | ...js.configs.recommended.rules 40 | } 41 | }]; 42 | -------------------------------------------------------------------------------- /src/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * webpack config to build the public module. 3 | * Devdeps in outer repo. 4 | * 5 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 6 | * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms. 7 | */ 8 | import * as path from 'node:path'; 9 | import webpack from 'webpack'; 10 | import pkgJson from '../package.json' with { type: 'json' }; 11 | import { stageDir, distDir } from './build-settings.js'; 12 | 13 | export default { 14 | mode: 'development', 15 | entry: path.join(stageDir, 'editable-object.js'), 16 | optimization: { 17 | nodeEnv: 'development', 18 | minimize: false, 19 | minimizer: [] 20 | }, 21 | experiments: { 22 | outputModule: true 23 | }, 24 | output: { 25 | path: distDir, 26 | filename: 'editable-object.js', 27 | library: { 28 | type: 'module' 29 | } 30 | }, 31 | module: { 32 | rules: [{ 33 | test: /\.js$/, 34 | loader: 'babel-loader' 35 | }] 36 | }, 37 | plugins: [ 38 | new webpack.DefinePlugin({ 39 | 'process.env': { 40 | NODE_ENV: JSON.stringify('development') 41 | } 42 | }), 43 | new webpack.BannerPlugin({ 44 | banner: `editable-object@${pkgJson.version}, Copyright (c) ${(new Date()).getFullYear()} Alex Grant (https://www.localnerve.com), LocalNerve LLC, BSD-3-Clause`, 45 | entryOnly: true 46 | }) 47 | ] 48 | }; -------------------------------------------------------------------------------- /src/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * webpack config to build the public module. 3 | * Devdeps in outer repo. 4 | * 5 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 6 | * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms. 7 | */ 8 | import * as path from 'node:path'; 9 | import webpack from 'webpack'; 10 | import TerserPlugin from 'terser-webpack-plugin'; 11 | import pkgJson from '../package.json' with { type: 'json' }; 12 | import { stageDir, distDir } from './build-settings.js'; 13 | 14 | export default { 15 | mode: 'production', 16 | entry: path.join(stageDir, 'editable-object.js'), 17 | optimization: { 18 | minimizer: [new TerserPlugin({ extractComments: false })], 19 | }, 20 | experiments: { 21 | outputModule: true 22 | }, 23 | output: { 24 | path: distDir, 25 | filename: 'editable-object.js', 26 | library: { 27 | type: 'module' 28 | } 29 | }, 30 | module: { 31 | rules: [{ 32 | test: /\.js$/, 33 | loader: 'babel-loader' 34 | }] 35 | }, 36 | plugins: [ 37 | new webpack.DefinePlugin({ 38 | 'process.env': { 39 | NODE_ENV: JSON.stringify('production') 40 | } 41 | }), 42 | new webpack.BannerPlugin({ 43 | banner: `editable-object@${pkgJson.version}, Copyright (c) ${(new Date()).getFullYear()} Alex Grant (https://www.localnerve.com), LocalNerve LLC, BSD-3-Clause`, 44 | entryOnly: true 45 | }) 46 | ] 47 | }; -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Alex Grant (https://www.localnerve.com), LocalNerve LLC 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | * Neither the name of the LocalNerve, LLC nor the 14 | names of its contributors may be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL LocalNerve, LLC BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /src/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Build script for editable-object web component 3 | * 4 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 5 | * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms. 6 | */ 7 | import * as path from 'node:path'; 8 | import * as fs from 'node:fs/promises'; 9 | import { build } from '@localnerve/web-component-build'; 10 | import { thisDir, stageDir, distDir } from './build-settings.js'; 11 | 12 | const cssFilePath = path.join(thisDir, 'editable-object.css'); 13 | const htmlFilePath = path.join(thisDir, 'editable-object.html'); 14 | const jsFilePath = path.join(thisDir, 'editable-object.js'); 15 | const jsReplacement = '__JS_REPLACEMENT__'; 16 | const indexFilePath = path.join(thisDir, 'index.js'); 17 | const testFixturePath = path.join(thisDir, '../test/fixtures'); 18 | 19 | /** 20 | * Main build script. 21 | */ 22 | async function buildwc () { 23 | await fs.rm(stageDir, { recursive: true, force: true }); 24 | await fs.mkdir(stageDir, { recursive: true }); 25 | 26 | const result = await build(stageDir, { 27 | cssPath: cssFilePath, 28 | htmlPath: htmlFilePath, 29 | jsPath: jsFilePath, 30 | jsReplacement, 31 | minifySkip: !!process.env.SKIP_MIN 32 | }); 33 | 34 | // INFO: webpack creates the dist bundle from stageDir 35 | 36 | await fs.cp(indexFilePath, path.join(distDir, path.basename(indexFilePath))); 37 | await fs.cp(result.cssPath, path.join(distDir, path.basename(result.cssPath))); 38 | 39 | // maintain test fixture copies 40 | await fs.cp(path.join(stageDir, path.basename(jsFilePath)), path.join(testFixturePath, path.basename(jsFilePath))); 41 | 42 | const normalizeCss = 'modern-normalize.css'; 43 | await fs.cp(path.join(thisDir, '../', 'node_modules/modern-normalize', normalizeCss), path.join(testFixturePath, normalizeCss)); 44 | } 45 | 46 | await buildwc(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@localnerve/editable-object", 3 | "version": "0.3.15", 4 | "description": "A vanillajs editable-object web component for visual object display and editing", 5 | "main": "dist/index.js", 6 | "browser": "dist/editable-object.js", 7 | "type": "module", 8 | "scripts": { 9 | "axe": "axe http://localhost:3010", 10 | "build:debug": "pushd src && node --inspect-brk build.js && popd", 11 | "build": "cd src && node build.js && webpack-cli --config webpack.prod.config.js && cd -", 12 | "build:dev": "cd src && node build.js && webpack-cli --config webpack.dev.config.js && cd -", 13 | "clean": "rimraf src/tmp", 14 | "lint": "npm run clean && eslint . && stylelint -f verbose src/**/*.css", 15 | "prepublishOnly": "npm run build", 16 | "test": "npm run test:server:bg && sleep 2 && npm run axe && curl -X POST http://localhost:3010/shutdown", 17 | "test:server": "SKIP_MIN=1 npm run build && node test/dev-server.js", 18 | "test:server:bg": "npm run test:server &" 19 | }, 20 | "devDependencies": { 21 | "@axe-core/cli": "^4.11.0", 22 | "@eslint/js": "^9.39.1", 23 | "@localnerve/web-component-build": "^1.13.2", 24 | "babel-loader": "10.0.0", 25 | "eslint": "^9.39.1", 26 | "express": "5.2.1", 27 | "globals": "^16.5.0", 28 | "modern-normalize": "^3.0.1", 29 | "rimraf": "^6.1.2", 30 | "stylelint": "^16.26.1", 31 | "stylelint-config-standard": "^39.0.1", 32 | "stylelint-no-unsupported-browser-features": "^8.0.5", 33 | "webpack-cli": "6.0.1" 34 | }, 35 | "files": [ 36 | "dist/**" 37 | ], 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/localnerve/editable-object.git" 41 | }, 42 | "keywords": [ 43 | "editable", 44 | "list", 45 | "webcomponent", 46 | "javascript", 47 | "vanillajs" 48 | ], 49 | "author": "Alex Grant (https://www.localnerve.com)", 50 | "license": "BSD-3-Clause", 51 | "bugs": { 52 | "url": "https://github.com/localnerve/editable-object/issues" 53 | }, 54 | "homepage": "https://github.com/localnerve/editable-object/tree/master/src/editable-object#readme" 55 | } 56 | -------------------------------------------------------------------------------- /test/fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Main Test Page 8 | 53 | 54 | 55 |
56 |

Test editable-object

57 | 58 |
59 | 60 | 82 | 83 | -------------------------------------------------------------------------------- /test/fixtures/no-data.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object with fluid-type 8 | 71 | 72 | 73 |
74 |

Test editable-object

75 | 76 |
77 | 78 | 86 | 87 | -------------------------------------------------------------------------------- /test/fixtures/add-property-placeholder.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object Add Property Placeholder 8 | 53 | 54 | 55 |
56 |

Test editable-object

57 | 58 |
59 | 60 | 82 | 83 | -------------------------------------------------------------------------------- /test/fixtures/fluid-type.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object with fluid-type 8 | 71 | 72 | 73 |
74 |

Test editable-object

75 | 76 |
77 | 78 | 100 | 101 | -------------------------------------------------------------------------------- /test/fixtures/repeat-assign.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object object setter 8 | 69 | 70 | 71 |
72 |

Test editable-object, Repeat Object Assignment

73 |
74 | 75 |
76 | 77 |
78 | 79 | 112 | 113 | -------------------------------------------------------------------------------- /test/fixtures/handlers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Handlers Test Page 8 | 69 | 70 | 71 |
72 |

Test editable-object

73 |
74 | Handler Results 75 |
76 |
77 | 78 | 79 |
80 |
81 | 82 | 83 |
84 |
85 | 86 | 87 |
88 |
89 |
90 | 91 |
92 | 93 | 127 | 128 | -------------------------------------------------------------------------------- /test/fixtures/disable-edit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object Disable Function 8 | 69 | 70 | 71 |
72 |

Test editable-object

73 |
74 | Disable Edit 75 |
76 |
77 | 78 | 79 |
80 |
81 | 82 | 83 |
84 |
85 |
86 | 87 |
88 | 89 | 123 | 124 | -------------------------------------------------------------------------------- /src/editable-object.css: -------------------------------------------------------------------------------- 1 | :host { 2 | --eo-min-width: 300px; 3 | --eo-bg-color: #fafafa; 4 | --eo-border-radius: 4px; 5 | --eo-border-focused-color: #444; 6 | --eo-border-defocused-color: #aaa; 7 | --eo-item-selected-bg-color: #999; 8 | --eo-item-selected-color: #222; 9 | --eo-item-selected-border-radius: 4px; 10 | --eo-item-hover-border-width: 1px; 11 | --eo-item-hover-border-color: #ddd; 12 | --eo-item-hover-border-radius: 4px; 13 | --eo-icon-color: #444; 14 | --eo-add-new-icon-color: #444; 15 | --eo-input-focus-outline-color: #26b; 16 | --eo-input-focus-outline-width: 1px; 17 | --eo-input-focus-outline-style: auto; 18 | --eo-input-border-color: #bbb; 19 | --eo-input-border-radius: 4px; 20 | --eo-input-bg-color: #444; 21 | --eo-input-color: #eee; 22 | --eo-input-font-family: sans-serif; 23 | --eo-input-placeholder-color: #aaa; 24 | } 25 | 26 | :host(.disabled) { 27 | pointer-events: none; 28 | } 29 | 30 | .editable-object { 31 | background: var(--eo-bg-color); 32 | border-radius: var(--eo-border-radius); 33 | min-width: var(--eo-min-width); 34 | display: flex; 35 | flex-flow: column nowrap; 36 | justify-content: center; 37 | align-items: center; 38 | } 39 | 40 | @media (min-width: 360px) { 41 | .editable-object { 42 | padding: 0 0.5rem; 43 | } 44 | } 45 | 46 | @media (min-width: 464px) { 47 | .editable-object { 48 | border: 1px solid var(--eo-border-focused-color); 49 | padding: 1rem; 50 | } 51 | 52 | .editable-object.defocused { 53 | border: 1px solid var(--eo-border-defocused-color); 54 | } 55 | } 56 | 57 | ul { 58 | padding: 0; 59 | margin: 0; 60 | width: 100%; 61 | } 62 | 63 | li { 64 | line-height: 2; 65 | list-style: none; 66 | cursor: default; 67 | padding: 0.5rem; 68 | } 69 | 70 | input { 71 | padding: 6px 8px; 72 | border-radius: var(--eo-input-border-radius); 73 | line-height: 1.5; 74 | border: 1px solid var(--eo-input-border-color); 75 | color: var(--eo-input-color); 76 | background: var(--eo-input-bg-color); 77 | font-family: var(--eo-input-font-family); 78 | } 79 | 80 | input:focus-visible { 81 | outline: var(--eo-input-focus-outline-color) var(--eo-input-focus-outline-style) var(--eo-input-focus-outline-width); 82 | } 83 | 84 | input::placeholder { 85 | color: var(--eo-input-placeholder-color); 86 | } 87 | 88 | .editable-object:not(.defocused) li.selected { 89 | background: var(--eo-item-selected-bg-color); 90 | color: var(--eo-item-selected-color); 91 | border-radius: var(--eo-item-selected-border-radius); 92 | } 93 | 94 | li, div > div { 95 | display: flex; 96 | align-items: center; 97 | justify-content: space-around; 98 | } 99 | 100 | .editable-object:not(.defocused, .touch) li:hover { 101 | border: var(--eo-item-hover-border-width) solid var(--eo-item-hover-border-color); 102 | border-radius: var(--eo-item-hover-border-radius); 103 | } 104 | 105 | .property-wrapper { 106 | display: flex; 107 | flex-flow: row wrap; 108 | flex-grow: 1; 109 | align-items: baseline; 110 | min-width: 10em; 111 | touch-action: manipulation; /* disable double-tap zoom for edit handlers */ 112 | } 113 | 114 | .property-wrapper label { 115 | flex: 1 1 100%; 116 | min-width: 8em; 117 | max-width: 100%; 118 | } 119 | 120 | .property-wrapper input { 121 | flex: 1; 122 | min-width: 10em; 123 | } 124 | 125 | .editable-object.defocused input { 126 | opacity: 0.7; 127 | } 128 | 129 | @media (min-width: 41.69em) { 130 | .property-wrapper label { 131 | flex-basis: auto; 132 | max-width: 45%; 133 | min-width: 15em; 134 | padding-right: 0.5rem; 135 | } 136 | 137 | .property-wrapper input { 138 | flex: 1 1; 139 | min-width: 16em; 140 | } 141 | } 142 | 143 | .toolbar { 144 | display: flex; 145 | gap: 1.5rem; 146 | padding-left: 1.5rem; 147 | } 148 | 149 | li:not(.selected) .toolbar, .editable-object.defocused .toolbar { 150 | opacity: 0; 151 | pointer-events: none; 152 | } 153 | 154 | .toolbar button { 155 | position: relative; 156 | fill: var(--eo-icon-color); 157 | } 158 | 159 | .editable-object-add-property.icon { 160 | fill: var(--eo-add-new-icon-color); 161 | } 162 | 163 | .toolbar button:hover::before { 164 | content: ""; 165 | position: absolute; 166 | width: 24px; 167 | height: 24px; 168 | background: rgb(0 0 0 / 10%); 169 | left: -4px; 170 | top: -4px; 171 | border-radius: 50%; 172 | } 173 | 174 | .icon { 175 | background-color: transparent; 176 | border: none; 177 | cursor: pointer; 178 | font-size: 0; 179 | fill: var(--eo-icon-color); 180 | padding: 0; 181 | } 182 | 183 | .icon svg { 184 | width: 1rem; 185 | height: 1rem; 186 | } 187 | 188 | .new-object-property { 189 | display: flex; 190 | flex-flow: row wrap; 191 | line-height: 2; 192 | margin-top: 2rem; 193 | width: 100%; 194 | } 195 | 196 | label[for="new-property"] { 197 | flex: 1 0 100%; 198 | } 199 | 200 | .add-new-object-property-input { 201 | min-width: 18em; 202 | flex: 1; 203 | } 204 | 205 | .add-new-object-property-input.error, 206 | .property-wrapper input.error { 207 | outline: red auto 1px; 208 | } 209 | 210 | .hide { 211 | display: none; 212 | } -------------------------------------------------------------------------------- /test/fixtures/spinner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test editable-object With A Loading Spinner 8 | 115 | 116 | 117 |
118 |

Test editable-object

119 | 120 |
121 |
122 |
123 | 124 | 125 | 151 | 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # editable-object 2 | [![npm version](https://badge.fury.io/js/%40localnerve%2Feditable-object.svg)](http://badge.fury.io/js/%40localnerve%2Feditable-object) 3 | 4 | > A small, fast, no-dependency, editable object web component. 5 | 6 | ## Overview 7 | 8 | A native web component for an editable object that allows a user to edit it's values, add or remove key/value pairs. JSON values only. 9 | Non-browser module exports build helpers (for building CSP rules, etc). 10 | 11 | _A convenient, **no-dependency** drop-in 'todo' app component to test/round-trip data updates and mutations on the front end._ 12 | 13 | ## Quick Links 14 | 15 | * [Events](#events) 16 | * [Attributes](#attributes-and-properties) 17 | * [Named Slots](#named-slots) 18 | * [Properties and Methods](#javascript-public-properties-and-methods) 19 | * [CSS Variables](#overridable-css-variables) 20 | * [Usage Examples](#usage-example) 21 | * [Non-browser exports](#nonbrowser-exports) 22 | 23 | ## Events 24 | 25 | This web component issues a 'changed' CustomEvent when an object property is added, edited, or removed. 26 | The format of the `event.detail` is as follows: 27 | 28 | ``` 29 | { 30 | action: 'add' | 'edit' | 'remove', 31 | key: '', 32 | previous: '', 33 | new: '' 34 | } 35 | ``` 36 | 37 | _[Event Usage Example](test/fixtures/handlers.html)_ 38 | 39 | ## Attributes (and Properties) 40 | 41 | * `object` - *Optional*. The initial object to edit - Must be a JSON stringified object. Can be added later without JSON stringification via the javascript property `object`. 42 | 43 | > Property name is also `object`. 44 | - [object Usage Example](test/fixtures/repeat-assign.html) 45 | 46 | * `add-property-placeholder` - *Optional*. The text that prompts a user to add a new property to the object. Defaults to 'Add new property in key:value format'. 47 | 48 | > Property name is `addPropertyPlaceholder`. 49 | - [addPropertyPlaceholder Usage Example](test/fixtures/add-property-placeholder.html) 50 | 51 | * `disable-edit` - *Optional*. Disallow the editing functions. Makes this component a read-only view of the object. 52 | 53 | > Property name is `disableEdit`. 54 | - [disableEdit Usage Example](test/fixtures/disable-edit.html) 55 | 56 | ## Named Slots 57 | 58 | * `"loading"` - *Optional*. A named slot you can use to bring in content to display during loading. Hidden after initial object parse or later when object is set. 59 | - [Slot Usage Example](test/fixtures/spinner.html) 60 | 61 | ## Javascript Public Properties and Methods 62 | 63 | * **Property** `object` {**Object**} - Assign a javascript `Object` to set the component's internals for editing. Any existing object is replaced. JSON compatible properties only (string, number, boolean, array, object, null). 64 | - [object Usage Example](test/fixtures/repeat-assign.html) 65 | 66 | * **Property** `addPropertyPlaceholder` {**String**} - Assign a prompt to show the user in the new property/value input box to override the default 'Add new property in key:value format'. 67 | - [addPropertyPlaceholder Usage Example](test/fixtures/add-property-placeholder.html) 68 | 69 | * **Property** `disableEdit` {**Boolean**} - Assign to true to make the control read-only and disallow any editing. 70 | - [disableEdit Usage Example](test/fixtures/disable-edit.html) 71 | 72 | * **Property** `onEdit` {**Function**} - Assign to a javascript function to be called on edit. Use to supply custom validation to an object property value before edit. Receives the property name and proposed new value from the user. Return true to allow the edit to proceed, or false to invalidate it. 73 | - [onEdit Usage Example](test/fixtures/handlers.html) 74 | 75 | * **Property** `onAdd` {**Function**} - Assign to a javascript function to be called on add. Use to supply custom validation to an object property value before add. Receives the new property name and proposed value from the user. Return true to allow the add to proceed, or false to invalidate it. 76 | - [onAdd Usage Example](test/fixtures/handlers.html) 77 | 78 | * **Property** `onRemove` {**Function**} - Assign to a javascript function to be called on remove. Can be used to supply custom validation to allow a property to be deleted. Receives the property name and value. Return true to allow the delete to proceed, or false to stop it. 79 | - [onRemove Usage Example](test/fixtures/handlers.html) 80 | 81 | * **Method** `mergeObject(newObject)` - Call to merge more properties into the underlying object under edit. 82 | 83 | ## Overridable CSS Variables 84 | 85 | * `--eo-min-width` - The min-width for the component. Defaults to 300px. 86 | * `--eo-bg-color` - The overall control background color. Defaults to #fafafa. 87 | * `--eo-border-radius` - The border-radius of the control. Defaults to 4px. 88 | * `--eo-border-focused-color` - The color of the control border when focused. Defaults to #444. 89 | * `--eo-border-defocused-color` - The color of the control border when not focused. Defaults to #aaa. 90 | * `--eo-item-selected-bg-color` - The background color of the property list item when selected. Defaults to #999. 91 | * `--eo-item-selected-color` - The foreground text color of the property list item when selected. Defaults to #222. 92 | * `--eo-item-selected-border-radius` - The border-radius of the item selection box. Defaults to 4px. 93 | * `--eo-item-hover-border-width` - The hover border width. Defaults to 1px. 94 | * `--eo-item-hover-border-color` - The hover border color. Defaults to #ddd. 95 | * `--eo-item-hover-border-radius` - The border-radius of the item hover box. Defaults to 4px. 96 | * `--eo-icon-color` - The color of the toolbar button icons. Defaults to #444. 97 | * `--eo-add-new-icon-color` - The color of the 'add new property' toolbar button icon. Defaults to #444. 98 | * `--eo-input-focus-outline-color` - The color of the focus ring on the input boxes. Defaults to #26b. 99 | * `--eo-input-focus-outline-width`- The width of the focus ring on the input boxes. Defaults to 1px. 100 | * `--eo-input-focus-outline-style` - The style of the focus ring on the input boxes. Defaults to 'auto'. 101 | * `--eo-input-border-color` - The border color of 'add' and 'edit' input boxes. Defaults to #bbb. 102 | * `--eo-input-border-radius` - The border radius of an input control. Defaults to 4px. 103 | * `--eo-input-bg-color` - The background color of input controls. Defaults to #444. 104 | * `--eo-input-color` - The foreground text color of input controls. Defaults to #eee. 105 | * `--eo-input-font-family` - The font family of input controls. Defaults to 'sans-serif'. 106 | * `--eo-input-placeholder-color` - The foreground text color of input placeholder text. Defaults to #aaa. 107 | 108 | 109 | ## Usage Example 110 | 111 | ```html 112 | 113 | ``` 114 | See [The test references](https://github.com/localnerve/editable-object/blob/master/test/fixtures) for more usage examples, run them using `npm run test:server`. 115 | * Slot [example](https://github.com/localnerve/editable-object/blob/master/test/fixtures/spinner.html) 116 | * Fluid type [example](https://github.com/localnerve/editable-object/blob/master/test/fixtures/fluid-type.html) 117 | * Disable edit [example](https://github.com/localnerve/editable-object/blob/master/test/fixtures/disable-edit.html) 118 | * Validation handlers [example](https://github.com/localnerve/editable-object/blob/master/test/fixtures/handlers.html) 119 | 120 | ## Non-browser Exports 121 | 122 | The non-browser version of the module exports methods to help with builds. 123 | 124 | ### {Promise} getEditableObjectCssText() 125 | 126 | Asynchronously gets the raw shadow css text. 127 | Useful for computing the hash for a CSP style rule. 128 | Returns a Promise that resolves to the full utf8 string of css text. 129 | 130 | - [getEditableObjectCssText Usage Example](https://github.com/localnerve/jam-build/blob/main/src/build/html.js#L24) 131 | 132 | ## License 133 | 134 | LocalNerve [BSD-3-Clause](https://github.com/localnerve/editable-object/blob/master/LICENSE.md) Licensed 135 | 136 | ## Contact 137 | 138 | twitter: @localnerve 139 | email: alex@localnerve.com -------------------------------------------------------------------------------- /src/editable-object.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A vanillajs editable-object web component. 3 | * 4 | * TODOs: 5 | * 1. gracefully handle connectedCallback object parse error 6 | * 7 | * Copyright (c) 2025 Alex Grant (@localnerve), LocalNerve LLC 8 | * Copyrights licensed under the MIT License. See the accompanying LICENSE file for terms. 9 | */ 10 | 11 | class EditableObject extends HTMLElement { 12 | #object = null; 13 | #disableEdit = false; 14 | #onEdit = ()=>true; 15 | #onRemove = ()=>true; 16 | #onAdd = ()=>true; 17 | 18 | // Element references and listeners for cleanup 19 | // [{ host, type, listener }, ...] 20 | #listeners = []; 21 | #objectListeners = []; 22 | #editListeners = []; 23 | 24 | // Observed attributes 25 | static #observedAttributes = ['object', 'add-property-placeholder', 'disable-edit']; 26 | static #observedAttributeDefaults = { 27 | object: {}, 28 | 'add-property-placeholder': 'Add new property in key:value format', 29 | 'disabled-edit': false 30 | }; 31 | static get observedAttributes () { 32 | return this.#observedAttributes; 33 | } 34 | 35 | constructor () { 36 | super(); 37 | this.attachShadow({ mode: 'open', delegatesFocus: true }); 38 | } 39 | 40 | /** 41 | * Get the value for an observed attribute by name. 42 | * 43 | * @param {String} attributeName - The attribute name for the property to get 44 | * @returns {String|Boolean} the value of the property 45 | */ 46 | #observedAttributeValue (attributeName) { 47 | if (this.hasAttribute(attributeName)) { 48 | const attributeValue = this.getAttribute(attributeName); 49 | if (/^\s*(?:true|false)\s*$/i.test(attributeValue)) { 50 | return attributeValue !== 'false'; 51 | } 52 | return attributeValue; 53 | } 54 | return EditableObject.#observedAttributeDefaults[attributeName]; 55 | } 56 | 57 | /** 58 | * Convert to string presentable representation. 59 | * 60 | * @param {Object} obj - The value 61 | * @returns {String} A string presentable value 62 | */ 63 | #_stringable (obj) { 64 | if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean' || obj === null) { 65 | return obj; 66 | } 67 | 68 | if (typeof obj === 'undefined' || typeof obj === 'function' || typeof obj === 'symbol') { 69 | return null; 70 | } 71 | 72 | if (typeof obj === 'bigint') { 73 | return `${obj}n`; 74 | } 75 | 76 | if (Object.prototype.toString.call(obj) === '[object RegExp]') { 77 | obj = { 78 | __pattern: obj.source, 79 | flags: obj.flags 80 | }; 81 | } 82 | 83 | let result = JSON.stringify(obj); 84 | if (result[0] === '{') { 85 | result = result.replaceAll('"', '\''); 86 | } 87 | return result; 88 | } 89 | 90 | /** 91 | * Convert a stringable value back into js. 92 | * 93 | * @param {String} val - The stringable value from the UI 94 | * @param {HTMLElement} validateElement - The element to add validation classes to for reporting 95 | * @returns {Any} The js value of the string, or some garbage if its really screwed up 96 | */ 97 | #_jsable (val, validateElement = null) { 98 | const value = val.trim(); 99 | 100 | let num = parseFloat(value); 101 | if (num) return num; 102 | 103 | if (/\d+n$/.test(value)) { 104 | return BigInt(value.slice(0, -1)); 105 | } 106 | 107 | if (value.toLowerCase() === 'false') return false; 108 | if (value.toLowerCase() === 'true') return true; 109 | if (value.toLowerCase() === 'null') return null; 110 | 111 | let isObjectInput = false; 112 | let result; 113 | try { 114 | let input = value; 115 | if (input[0] === '{') { 116 | isObjectInput = true; 117 | input = input.replaceAll('\'', '"'); 118 | } 119 | const objOrArray = JSON.parse(input); 120 | if (Object.keys(objOrArray).includes('__pattern')) { 121 | result = new RegExp(objOrArray.__pattern, objOrArray.flags); 122 | } else { 123 | result = objOrArray; 124 | } 125 | } catch { 126 | if (isObjectInput && validateElement) { 127 | validateElement.classList.add('error'); 128 | throw new Error('Bad object input'); 129 | } 130 | result = value; // plain old string or real unparsable trash 131 | } 132 | 133 | return result; 134 | } 135 | 136 | /** 137 | * Generates the inner HTML of the li of each property 138 | * 139 | * @param {String} key - The property key 140 | * @param {String} value - The property value 141 | * @returns {String} The property's HTML code 142 | */ 143 | #_propertyHTML (key, value) { 144 | return ` 145 |
146 | 147 | 148 |
149 |
150 | 155 | 160 | 165 |
166 | `; 167 | } 168 | 169 | /** 170 | * Update the toolbar buttons of all editable object properties. 171 | */ 172 | #_updateToolbars () { 173 | const ups = this.shadowRoot.querySelectorAll('.editable-object-up-property'); 174 | const downs = this.shadowRoot.querySelectorAll('.editable-object-down-property'); 175 | const len = ups.length; 176 | 177 | for (let i = 0; i < len; i++) { 178 | ups[i].style.visibility = (i == 0 ? 'hidden' : 'visible'); 179 | downs[i].style.visibility = (i == len - 1 ? 'hidden' : 'visible'); 180 | } 181 | 182 | const method = this.#disableEdit ? 'add' : 'remove'; 183 | const deletes = this.shadowRoot.querySelectorAll('.editable-object-remove-property'); 184 | deletes.forEach(button => button.classList[method]('hide')); 185 | } 186 | 187 | /** 188 | * Test for touchscreen. 189 | * 190 | * @returns {Boolean} true if touch, false otherwise 191 | */ 192 | #_isTouch () { 193 | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 194 | } 195 | 196 | /** 197 | * Get the containing li element from an element inside it. 198 | * 199 | * @param {HTMLElement} - Element an element inside the item li 200 | * @returns {HTMLElement} The item li 201 | */ 202 | #_getLi (element) { 203 | while (element.tagName !== 'LI') element = element.parentNode; 204 | return element; 205 | } 206 | 207 | /** 208 | * Fires when an editable object property is selected. 209 | * 210 | * @param {Event} e - The caller event object 211 | */ 212 | #_liSelected (e) { 213 | this.#_cleanSelection(); 214 | const li = this.#_getLi(e.target); 215 | li.classList.toggle('selected', true); 216 | // set tabindex on child buttons 217 | [...li.querySelectorAll('button')].forEach(button => { 218 | button.tabIndex = 0; 219 | }); 220 | // give focus to the input element inside 221 | li.querySelector('input').focus(); 222 | } 223 | 224 | /** 225 | * Creates a change event for external scripts monitoring the component. 226 | * 227 | * @param {Object} data - Component change data 228 | * @returns {CustomEvent} - The CustomEvent for the change 229 | */ 230 | #_changeEvent (data) { 231 | return new CustomEvent('change', { 232 | bubbles: true, 233 | cancelable: false, 234 | composed: true, 235 | detail: data 236 | }); 237 | } 238 | 239 | /** 240 | * Handle enter, spacebar keypress in li elements as alternate selection. 241 | * 242 | * @param {Event} e - The event object 243 | */ 244 | #_propertyInputKeySelect (e) { 245 | if (e.target.nodeName === 'INPUT' && !e.target.classList.contains('error')) { 246 | if (e.key === 'Enter' || e.key === ' ') { 247 | e.target.click(); 248 | } 249 | } 250 | } 251 | 252 | /** 253 | * Start editing a property. 254 | * 255 | * @param {Event} e - The event object 256 | */ 257 | #_propertyEditStart (e) { 258 | if (this._editing) return; 259 | this._editing = true; 260 | 261 | const li = this.#_getLi(e.target); 262 | const inp = li.querySelector('.property-wrapper > input'); 263 | 264 | inp.classList.remove('error'); 265 | 266 | inp.readOnly = false; 267 | inp._value = inp.value; 268 | 269 | const blurHandler = this.#_propertyEditFinish.bind(this); 270 | const keypressHandler = this.#_propertyEditFinish.bind(this); 271 | 272 | inp.addEventListener('blur', blurHandler, false); 273 | this.#editListeners.push({ 274 | host: inp, 275 | type: 'blur', 276 | listener: blurHandler 277 | }); 278 | inp.addEventListener('keypress', keypressHandler, false); 279 | this.#editListeners.push({ 280 | host: inp, 281 | type: 'keypress', 282 | listener: keypressHandler 283 | }); 284 | 285 | inp.focus(); 286 | } 287 | 288 | /** 289 | * Property editing finished. 290 | * 291 | * @param {Event} e - The event object 292 | */ 293 | #_propertyEditFinish (e) { 294 | if (!this._editing) return; 295 | if ((e instanceof KeyboardEvent && e.key === 'Enter') || !(e instanceof KeyboardEvent)) { 296 | e.preventDefault(); 297 | 298 | const li = this.#_getLi(e.target); 299 | const key = li.querySelector('.property-wrapper > label').innerText; 300 | const inp = li.querySelector('.property-wrapper > input'); 301 | 302 | const previousValue = inp._value; 303 | const newValue = inp.value; 304 | 305 | this._editing = false; 306 | inp.readOnly = true; 307 | 308 | let jsValue; 309 | let badInput = false; 310 | try { 311 | jsValue = this.#_jsable(newValue, inp); 312 | } catch { 313 | badInput = true; 314 | } 315 | 316 | this.#editListeners.forEach(editListener => { 317 | editListener.host.removeEventListener(editListener.type, editListener.listener); 318 | }); 319 | this.#editListeners.length = 0; 320 | 321 | if (!badInput) { 322 | if (this.#onEdit(key, jsValue)) { 323 | 324 | this.#object[key] = jsValue; 325 | 326 | this.dispatchEvent(this.#_changeEvent({ 327 | action: 'edit', 328 | key, 329 | previous: this.#_jsable(previousValue), 330 | new: jsValue 331 | })); 332 | } else { 333 | inp.classList.add('error'); 334 | } 335 | } else { 336 | inp.classList.add('error'); 337 | } 338 | } 339 | } 340 | 341 | /** 342 | * Move a ListItem up. 343 | * 344 | * @param {HTMLElement} li - The item li to move 345 | * @returns {bool} true if succeeded, false otherwise 346 | */ 347 | #_moveUpListItem (li) { 348 | const prev = li.previousElementSibling; 349 | if (!prev) return false; 350 | li.parentNode.insertBefore(li, prev); 351 | this.#_updateToolbars(); 352 | li.querySelector('.editable-object-up-property').focus(); 353 | return true; 354 | } 355 | 356 | /** 357 | * Move a ListItem down. 358 | * 359 | * @param {HTMLElement} li - The item li to move 360 | * @returns {bool} true if succeeded, false otherwise 361 | */ 362 | #_moveDownListItem (li) { 363 | const next = li.nextElementSibling; 364 | if (!next) return false; 365 | li.parentNode.insertBefore(next, li); 366 | this.#_updateToolbars(); 367 | li.querySelector('.editable-object-down-property').focus(); 368 | return true; 369 | } 370 | 371 | /** 372 | * Removes a ListItem. 373 | * 374 | * @param {HTMLElement} li - The item li to remove 375 | */ 376 | #_removeListItem (li) { 377 | li.remove(); 378 | this.#_updateToolbars(); 379 | } 380 | 381 | /** 382 | * Fires when a property is removed. 383 | * 384 | * @param {Event} e the caller event object 385 | */ 386 | #_removeListItemHandler (e) { 387 | e.stopPropagation(); 388 | 389 | const li = this.#_getLi(e.target); 390 | const key = li.querySelector('.property-wrapper > label').innerText.trim(); 391 | const inp = li.querySelector('.property-wrapper > input'); 392 | const value = inp.value.trim(); 393 | 394 | const jsValue = this.#_jsable(value); 395 | 396 | if (this.#onRemove(key, jsValue)) { 397 | this.#_removeListItem(li); 398 | delete this.#object[key]; 399 | 400 | this.dispatchEvent(this.#_changeEvent({ 401 | action: 'remove', 402 | key, 403 | previous: jsValue, 404 | new: null 405 | })); 406 | } else { 407 | inp.classList.add('error'); 408 | } 409 | } 410 | 411 | /** 412 | * Fires when a property is moved up. 413 | * 414 | * @param {Event} e - The caller event object 415 | */ 416 | #_moveUpListItemHandler (e) { 417 | e.stopPropagation(); 418 | const li = this.#_getLi(e.target); 419 | this.#_moveUpListItem(li); 420 | } 421 | 422 | /** 423 | * Fires when a property is moved down. 424 | * 425 | * @param {Event} e - The caller event object 426 | */ 427 | #_moveDownListItemHandler (e) { 428 | e.stopPropagation(); 429 | const li = this.#_getLi(e.target); 430 | this.#_moveDownListItem(li); 431 | } 432 | 433 | /** 434 | * Create a double-tap handler. 435 | * 436 | * @returns {function} A double-tap handler 437 | */ 438 | #_createDoubleTapHandler () { 439 | let lastTap = 0; 440 | let timeout; 441 | const detectDoubleTap = function(e) { 442 | const curTime = new Date().getTime(); 443 | const tapLen = curTime - lastTap; 444 | if (tapLen < 500 && tapLen > 0) { 445 | e.preventDefault(); 446 | this.#_propertyEditStart(e); 447 | } 448 | else { 449 | timeout = setTimeout(() => { 450 | clearTimeout(timeout); 451 | }, 500); 452 | } 453 | lastTap = curTime; 454 | }; 455 | return detectDoubleTap.bind(this); 456 | } 457 | 458 | /** 459 | * Attaches click handlers to all properties. 460 | * ** ONLY CALL AT connectCallback, object set, mergeObject ** 461 | * 462 | * @param {Array} lis - A list of li elements of the properties 463 | */ 464 | #_handleLiListeners (lis) { 465 | const listener = this.#_liSelected.bind(this); 466 | lis.forEach(element => { 467 | element.addEventListener('click', listener, false); 468 | this.#objectListeners.push({ 469 | host: element, 470 | type: 'click', 471 | listener 472 | }); 473 | }); 474 | } 475 | 476 | /** 477 | * Update the listeners for item editing. 478 | */ 479 | #_updateItemEditListeners () { 480 | const items = this.shadowRoot.querySelectorAll('.object-properties .property-wrapper'); 481 | const buttons = { 482 | remove: this.shadowRoot.querySelectorAll('.object-properties button[title="Remove"]') 483 | }; 484 | 485 | this.#_handleItemEditListeners(items, buttons); 486 | } 487 | 488 | /** 489 | * Attaches event handlers to the elements of each item inside the li, specifically those around `this.#disableEdit`. 490 | * 491 | * @param {Array} items - A list of elements inside the item li 492 | * @param {Array} buttons - A list of buttons inside the item li 493 | */ 494 | #_handleItemEditListeners (items, buttons) { 495 | const isTouch = this.#_isTouch(); 496 | 497 | const doubleTapEditHandler = this.#_createDoubleTapHandler(); 498 | const editHandler = this.#_propertyEditStart.bind(this); 499 | const removeClickHandler = this.#_removeListItemHandler.bind(this); 500 | 501 | if (!this.#disableEdit) { 502 | this._editing = false; 503 | items.forEach(element => { 504 | element.addEventListener('dblclick', editHandler, false); 505 | this.#objectListeners.push({ 506 | host: element, 507 | type: 'dblclick', 508 | listener: editHandler 509 | }); 510 | if (isTouch) { 511 | element.addEventListener('touchend', doubleTapEditHandler); 512 | this.#objectListeners.push({ 513 | host: element, 514 | type: 'touchend', 515 | listener: doubleTapEditHandler 516 | }); 517 | } 518 | }); 519 | buttons.remove.forEach(element => { 520 | element.addEventListener('click', removeClickHandler, false); 521 | this.#objectListeners.push({ 522 | host: element, 523 | type: 'click', 524 | listener: removeClickHandler 525 | }) 526 | }); 527 | } else { 528 | const itemListenerTypes = ['dblclick', 'touchend']; 529 | const listenerIndexes = []; 530 | 531 | const listeners = this.#objectListeners.filter((def, i) => { 532 | let result = itemListenerTypes.includes(def.type); 533 | if (result) { 534 | listenerIndexes.push(i); 535 | } else if (def.host.title.toLowerCase() === 'remove') { 536 | result = true; 537 | listenerIndexes.push(i); 538 | } 539 | return result; 540 | }); 541 | listeners.forEach(def => { 542 | def.host.removeEventListener(def.type, def.listener); 543 | }); 544 | let deletions = 0; 545 | for (const i of listenerIndexes) { 546 | this.#objectListeners.splice(i - deletions++, 1); 547 | } 548 | } 549 | } 550 | 551 | /** 552 | * Attaches event handlers to the elements of each item inside the li. 553 | * 554 | * @param {Array} items - A list of elements inside the item li 555 | * @param {Array} buttons - A list of buttons inside the item li 556 | */ 557 | #_handleItemListeners (items, buttons) { 558 | const keypressHandler = this.#_propertyInputKeySelect.bind(this); 559 | const moveUpClickHandler = this.#_moveUpListItemHandler.bind(this); 560 | const moveDownClickHandler = this.#_moveDownListItemHandler.bind(this); 561 | 562 | items.forEach(element => { 563 | element.addEventListener('keypress', keypressHandler, false); 564 | this.#objectListeners.push({ 565 | host: element, 566 | type: 'keypress', 567 | listener: keypressHandler 568 | }); 569 | }); 570 | buttons.up.forEach(element => { 571 | element.addEventListener('click', moveUpClickHandler, false); 572 | this.#objectListeners.push({ 573 | host: element, 574 | type: 'click', 575 | listener: moveUpClickHandler 576 | }); 577 | }); 578 | buttons.down.forEach(element => { 579 | element.addEventListener('click', moveDownClickHandler, false); 580 | this.#objectListeners.push({ 581 | host: element, 582 | type: 'click', 583 | listener: moveDownClickHandler 584 | }); 585 | }); 586 | 587 | this.#_handleItemEditListeners(items, buttons); 588 | } 589 | 590 | /** 591 | * Handler that checks if the editable object control has lost focus. 592 | * 593 | * @param {PointerEvent} e - The event object 594 | */ 595 | #_defocusEditableObject (e) { 596 | if (!e.composedPath().includes(this)) { 597 | this.shadowRoot.querySelector('.editable-object').classList.add('defocused'); 598 | } 599 | } 600 | 601 | /** 602 | * Handler that checks if the editable object control has gained focus. 603 | * 604 | * @param {PointerEvent} e - The event object 605 | */ 606 | #_setFocus () { 607 | this.shadowRoot.querySelector('.editable-object').classList.remove('defocused'); 608 | } 609 | 610 | #_keyExists (key) { 611 | return key in this.#object; 612 | } 613 | 614 | /** 615 | * Unselect all editable object properties, clear any error class 616 | */ 617 | #_cleanSelection() { 618 | const newPropEl = this.shadowRoot.querySelector('.add-new-object-property-input'); 619 | newPropEl.classList.toggle('error', false); 620 | 621 | [...this.shadowRoot.querySelectorAll('li')].forEach(element => { 622 | const inp = element.querySelector('.property-wrapper > input'); 623 | const hasError = inp.classList.contains('error'); 624 | if (hasError && inp._value) { 625 | inp.value = inp._value; 626 | } 627 | inp.classList.toggle('error', false); 628 | element.classList.toggle('selected', false); 629 | // disable tabindex on all buttons 630 | [...element.querySelectorAll('button')].forEach(button => { 631 | button.tabIndex = -1; 632 | }) 633 | }); 634 | } 635 | 636 | /** 637 | * Handler to add property to the editable object. 638 | * 639 | * @param {Event} e - The event object 640 | */ 641 | #_addNewProperty (e) { 642 | if ((e instanceof KeyboardEvent && e.key === 'Enter') || !(e instanceof KeyboardEvent)) { 643 | const textInput = this.shadowRoot.querySelector('.add-new-object-property-input'); 644 | const rawInput = textInput.value.trim(); 645 | 646 | if (rawInput !== '') { 647 | const re = /^\s*(?[^\s:]+)\s*:\s*(?[^$]+)$/; 648 | const captures = rawInput.match(re)?.groups; 649 | const [rawKey, rawValue] = captures ? Object.values(captures) : ['','']; 650 | const key = rawKey.trim(); 651 | const value = rawValue.trim(); 652 | 653 | const error = () => { 654 | textInput.classList.add('error'); 655 | textInput.focus(); 656 | } 657 | 658 | if (!key || !value || this.#_keyExists(key)) { 659 | error(); 660 | return; 661 | } 662 | 663 | let badInput = false; 664 | let jsValue; 665 | try { 666 | jsValue = this.#_jsable(value, textInput); 667 | } catch { 668 | badInput = true; 669 | } 670 | 671 | if (!badInput) { 672 | if (this.#onAdd(key, jsValue)) { 673 | const newProperty = { [key]: value }; // TODO: should be jsValue? 674 | this.mergeObject(newProperty); 675 | 676 | // Tell listeners of the mutation event 677 | this.dispatchEvent(this.#_changeEvent({ 678 | action: 'add', 679 | key, 680 | previous: null, 681 | new: jsValue 682 | })); 683 | 684 | const objectProperties = this.shadowRoot.querySelector('.object-properties'); 685 | objectProperties.lastChild.click(); 686 | textInput.value = ''; 687 | } else { 688 | error(); 689 | } 690 | } else { 691 | error(); 692 | } 693 | } 694 | } 695 | } 696 | 697 | /** 698 | * Setup handlers and visibility for the add new property input control on `this.#disableEdit`. 699 | */ 700 | #_setupAddNewProperty () { 701 | const { shadowRoot } = this; 702 | const newPropertyWrapper = shadowRoot.querySelector('.new-object-property'); 703 | const addElementInput = shadowRoot.querySelector('.add-new-object-property-input'); 704 | const addElementButton = shadowRoot.querySelector('.editable-object-add-property'); 705 | 706 | if (!this.#disableEdit) { 707 | const addPropPlaceholder = this.getAttribute('add-property-placeholder'); 708 | this.addPropertyPlaceholder = addPropPlaceholder; 709 | 710 | const cleanSelection = this.#_cleanSelection.bind(this); 711 | newPropertyWrapper.addEventListener('click', cleanSelection, true); 712 | this.#listeners.push({ host: newPropertyWrapper, type: 'click', listener: cleanSelection }); 713 | 714 | const addPropListener = this.#_addNewProperty.bind(this); 715 | addElementInput.addEventListener('keypress', addPropListener, false); 716 | this.#listeners.push({ host: addElementInput, type: 'keypress', listener: addPropListener }); 717 | addElementButton.addEventListener('click', addPropListener, false); 718 | this.#listeners.push({ host: addElementButton, type: 'click', listener: addPropListener }); 719 | 720 | newPropertyWrapper.classList.remove('hide'); 721 | } else { 722 | const listenerHosts = [newPropertyWrapper, addElementInput, addElementButton]; 723 | 724 | const addNewPropListenerIndexes = []; 725 | const addNewPropListeners = this.#listeners.filter((def, i) => { 726 | const result = listenerHosts.includes(def.host); 727 | if (result) { 728 | addNewPropListenerIndexes.push(i); 729 | } 730 | return result; 731 | }); 732 | addNewPropListeners.forEach(def => { 733 | def.host.removeEventListener(def.type, def.listener); 734 | }); 735 | let deletions = 0; 736 | for (const i of addNewPropListenerIndexes) { 737 | this.#listeners.splice(i - deletions++, 1); 738 | } 739 | 740 | newPropertyWrapper.classList.add('hide'); 741 | } 742 | } 743 | 744 | /// ---------------------------------------------- 745 | /// WebComponent public properties and methods 746 | /// ---------------------------------------------- 747 | 748 | /** 749 | * Get the underlying object. 750 | * 751 | * @returns {Object} The object being edited 752 | */ 753 | get object () { 754 | return this.#object; 755 | } 756 | 757 | /** 758 | * Set the object to be edited, replacing any existing object. 759 | * 760 | * @param {Object} obj - The new object to edit 761 | */ 762 | set object (obj) { 763 | if (!obj) return; 764 | 765 | if (this.#object && Object.keys(this.#object).length > 0) { 766 | [this.#objectListeners, this.#editListeners].forEach(listeners => { 767 | listeners.forEach(listenerObj => { 768 | listenerObj.host.removeEventListener(listenerObj.type, listenerObj.listener); 769 | }); 770 | }); 771 | } 772 | 773 | const loading = this.shadowRoot.querySelector('#loading'); 774 | const propContainer = this.shadowRoot.querySelector('.object-properties'); 775 | propContainer.innerHTML = ''; 776 | 777 | const lis = []; 778 | const items = []; 779 | const buttons = { 780 | up: [], 781 | down: [], 782 | remove: [] 783 | }; 784 | 785 | for (const [key, value] of Object.entries(obj)) { 786 | const li = document.createElement('li'); 787 | li.innerHTML = this.#_propertyHTML(key, this.#_stringable(value)); 788 | propContainer.appendChild(li); 789 | lis.push(li); 790 | items.push(li.querySelector('.property-wrapper')); 791 | buttons.up.push(li.querySelector('.editable-object-up-property')); 792 | buttons.down.push(li.querySelector('.editable-object-down-property')); 793 | buttons.remove.push(li.querySelector('.editable-object-remove-property')); 794 | } 795 | this.#_handleLiListeners(lis); 796 | this.#_handleItemListeners(items, buttons); 797 | this.#_updateToolbars(); 798 | 799 | loading.classList.add('hide'); 800 | 801 | this.#object = obj; 802 | } 803 | 804 | /** 805 | * Get the add-property-placeholder attribute value. 806 | * 807 | * @returns {String} The add property placeholder prompt string 808 | */ 809 | get addPropertyPlaceholder () { 810 | return this.#observedAttributeValue('add-property-placeholder'); 811 | } 812 | 813 | /** 814 | * Set the add-property-placeholder attribute. 815 | * 816 | * @param {String} value - The new add-property-placeholder prompt string 817 | */ 818 | set addPropertyPlaceholder (value) { 819 | const attributeName = 'add-property-placeholder'; 820 | const input = this.shadowRoot.querySelector('.add-new-object-property-input'); 821 | if (value) { 822 | this.setAttribute(attributeName, value); 823 | input.placeholder = value; 824 | } else { 825 | this.removeAttribute(attributeName); 826 | input.placeholder = EditableObject.#observedAttributeDefaults[attributeName]; 827 | } 828 | } 829 | 830 | /** 831 | * Set the disable-edit attribute. 832 | * 833 | * @param {Boolean} value - The new disable-edit value 834 | */ 835 | set disableEdit (value) { 836 | const attributeName = 'disable-edit'; 837 | if (value) { 838 | this.setAttribute(attributeName, true); 839 | this.#disableEdit = true; 840 | } else { 841 | this.setAttribute(attributeName, false); 842 | this.#disableEdit = false; 843 | } 844 | 845 | this.#_setupAddNewProperty(); 846 | this.#_updateItemEditListeners(); 847 | this.#_updateToolbars(); 848 | } 849 | 850 | /** 851 | * Get the disable-edit attribute. 852 | * 853 | * @returns {Boolean} The disable-edit value 854 | */ 855 | get disableEdit () { 856 | return this.#observedAttributeValue('disable-edit'); 857 | } 858 | 859 | /** 860 | * Set an onEdit handler. 861 | * 862 | * @param {Function} value - The new onEdit handler 863 | */ 864 | set onEdit (value) { 865 | if (typeof value === 'function') { 866 | this.#onEdit = value; 867 | } else { 868 | this.#onEdit = ()=>true; 869 | } 870 | } 871 | 872 | /** 873 | * Get the onEdit handler. 874 | * 875 | * @returns {Function} The onEdit handler 876 | */ 877 | get onEdit () { 878 | return this.#onEdit; 879 | } 880 | 881 | /** 882 | * Set an onAdd handler. 883 | * 884 | * @param {Function} value - The new onAdd handler 885 | */ 886 | set onAdd (value) { 887 | if (typeof value === 'function') { 888 | this.#onAdd = value; 889 | } else { 890 | this.#onAdd = ()=>true; 891 | } 892 | } 893 | 894 | /** 895 | * Get the onAdd handler. 896 | * 897 | * @returns {Function} The onAdd handler 898 | */ 899 | get onAdd () { 900 | return this.#onAdd; 901 | } 902 | 903 | /** 904 | * Set an onRemove handler. 905 | * 906 | * @param {Function} value - The new onRemove handler 907 | */ 908 | set onRemove (value) { 909 | if (typeof value === 'function') { 910 | this.#onRemove = value; 911 | } else { 912 | this.#onRemove = ()=>true; 913 | } 914 | } 915 | 916 | /** 917 | * Get the onRemove handler. 918 | * 919 | * @returns {Function} The onRemove handler 920 | */ 921 | get onRemove () { 922 | return this.#onRemove; 923 | } 924 | 925 | /** 926 | * Merge a new object into the existing object being edited, add 927 | */ 928 | mergeObject (newObj) { 929 | this.object = { ...this.#object, ...newObj }; 930 | } 931 | 932 | /// ---------------------------------------------- 933 | /// WebComponent lifecycle methods 934 | /// ---------------------------------------------- 935 | 936 | connectedCallback () { 937 | const { shadowRoot } = this; 938 | 939 | shadowRoot.innerHTML = '__JS_REPLACEMENT__'; 940 | 941 | const objAttr = this.getAttribute('object'); 942 | this.object = JSON.parse(objAttr); // TODO: could throw on bad input, bad input show user error 943 | 944 | // don't use the property here, side effects 945 | const disableEdit = this.getAttribute('disable-edit'); 946 | this.#disableEdit = disableEdit?.toLowerCase() === 'true' ? true : false; 947 | 948 | const container = shadowRoot.querySelector('.editable-object'); 949 | const loading = shadowRoot.querySelector('#loading'); 950 | 951 | const isTouch = this.#_isTouch(); 952 | const docClickListener = this.#_defocusEditableObject.bind(this); 953 | const containerFocusListener = this.#_setFocus.bind(this); 954 | 955 | if (this.object) { 956 | loading.classList.add('hide'); 957 | } 958 | 959 | if (isTouch) { 960 | container.classList.add('touch'); 961 | } 962 | 963 | document.addEventListener('click', docClickListener, false); 964 | this.#listeners.push({ host: document, type: 'click', listener: docClickListener }); 965 | container.addEventListener('click', containerFocusListener, true); 966 | this.#listeners.push({ host: container, type: 'click', listener: containerFocusListener }); 967 | 968 | this.#_setupAddNewProperty(); 969 | } 970 | 971 | disconnectedCallback () { 972 | [this.#listeners, this.#objectListeners, this.#editListeners].forEach(listenArr => { 973 | listenArr.forEach(listenObj => { 974 | listenObj.host.removeEventListener(listenObj.type, listenObj.listener); 975 | }); 976 | }); 977 | } 978 | 979 | attributeChangedCallback (attrName, oldValue, newValue) { 980 | if (newValue !== oldValue) { 981 | this[attrName] = this.getAttribute(attrName); 982 | } 983 | } 984 | } 985 | 986 | customElements.define('editable-object', EditableObject); --------------------------------------------------------------------------------