├── .npmrc ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── dist ├── test │ ├── get.test.d.ts │ ├── change.test.d.ts │ ├── disconnect.test.d.ts │ ├── connect.test.d.ts │ ├── get.test.d.ts.map │ ├── change.test.d.ts.map │ ├── disconnect.test.d.ts.map │ ├── connect.test.d.ts.map │ ├── change.test.js.map │ ├── get.test.js.map │ ├── change.test.js │ ├── get.test.js │ ├── disconnect.test.js.map │ ├── connect.test.js.map │ ├── disconnect.test.js │ └── connect.test.js ├── index.d.ts.map ├── index.d.ts ├── CustomAttributeRegistry.d.ts.map ├── index.js.map ├── CustomAttributeRegistry.d.ts ├── index.js ├── CustomAttributeRegistry.js.map └── CustomAttributeRegistry.js ├── tsconfig.json ├── .editorconfig ├── .npmignore ├── src ├── test │ ├── change.test.ts │ ├── get.test.ts │ ├── disconnect.test.ts │ └── connect.test.ts ├── index.ts └── CustomAttributeRegistry.ts ├── .github └── workflows │ └── tests.yml ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@lume/cli/.prettierrc.js') 2 | -------------------------------------------------------------------------------- /dist/test/get.test.d.ts: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | //# sourceMappingURL=get.test.d.ts.map -------------------------------------------------------------------------------- /dist/test/change.test.d.ts: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | //# sourceMappingURL=change.test.d.ts.map -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@lume/cli/config/ts.config.json" 3 | } 4 | -------------------------------------------------------------------------------- /dist/test/disconnect.test.d.ts: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | //# sourceMappingURL=disconnect.test.d.ts.map -------------------------------------------------------------------------------- /dist/test/connect.test.d.ts: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | declare global { 3 | function expect(...args: any[]): any; 4 | } 5 | //# sourceMappingURL=connect.test.d.ts.map -------------------------------------------------------------------------------- /dist/test/get.test.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"get.test.d.ts","sourceRoot":"","sources":["../../src/test/get.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA"} -------------------------------------------------------------------------------- /dist/test/change.test.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"change.test.d.ts","sourceRoot":"","sources":["../../src/test/change.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA"} -------------------------------------------------------------------------------- /dist/test/disconnect.test.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"disconnect.test.d.ts","sourceRoot":"","sources":["../../src/test/disconnect.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA"} -------------------------------------------------------------------------------- /dist/test/connect.test.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"connect.test.d.ts","sourceRoot":"","sources":["../../src/test/connect.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,CAAC,MAAM,CAAC;IACd,SAAS,MAAM,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,CAAA;CACpC"} -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = tab 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.{yml,yaml}] 18 | indent_style = space 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /dist/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAC,uBAAuB,EAAC,MAAM,8BAA8B,CAAA;AAEpE,cAAc,8BAA8B,CAAA;AAE5C,eAAO,IAAI,gBAAgB,EAAE,uBAAuB,CAAA;AAiBpD,OAAO,CAAC,MAAM,CAAC;IAMd,IAAI,gBAAgB,EAAE,uBAAuB,CAAA;IAE7C,UAAU,UAAU;QACnB,gBAAgB,EAAE,uBAAuB,CAAA;KACzC;IAED,UAAU,MAAM;QACf,gBAAgB,EAAE,uBAAuB,CAAA;KACzC;CACD;AAED,eAAO,MAAM,OAAO,UAAU,CAAA"} -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Specificity (mot just order) of the following rules matters. 2 | 3 | # Ignore everything, 4 | /**/* 5 | 6 | # but include these folders 7 | !/dist/**/* 8 | !/src/**/* 9 | 10 | # except for these files in the above folders. 11 | /dist/**/*.test.* 12 | /dist/tests/**/* 13 | /src/**/*.test.* 14 | /src/tests/**/* 15 | 16 | # The following won't work as you think it would. The test files will not be ignored as you may assume. 17 | # /**/* 18 | # !/dist/**/* 19 | # !/src/**/* 20 | # /**/*.test.* 21 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CustomAttributeRegistry } from './CustomAttributeRegistry.js'; 2 | export * from './CustomAttributeRegistry.js'; 3 | export declare let customAttributes: CustomAttributeRegistry; 4 | declare global { 5 | var customAttributes: CustomAttributeRegistry; 6 | interface ShadowRoot { 7 | customAttributes: CustomAttributeRegistry; 8 | } 9 | interface Window { 10 | customAttributes: CustomAttributeRegistry; 11 | } 12 | } 13 | export declare const version = "0.3.0"; 14 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /dist/CustomAttributeRegistry.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"CustomAttributeRegistry.d.ts","sourceRoot":"","sources":["../src/CustomAttributeRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,8BAA8B,CAAA;AAI7D,qBAAa,uBAAuB;;IAmBhB,aAAa,EAAE,QAAQ,GAAG,UAAU;gBAApC,aAAa,EAAE,QAAQ,GAAG,UAAU;IAIvD,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW;IAM3C,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM;CA8FtC;AAGD,MAAM,WAAW,eAAe;IAC/B,YAAY,EAAE,OAAO,CAAA;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,iBAAiB,CAAC,IAAI,IAAI,CAAA;IAC1B,oBAAoB,CAAC,IAAI,IAAI,CAAA;IAC7B,eAAe,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1D"} -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,gFAAgF;AAChF,cAAc;AAEd,OAAO,EAAC,uBAAuB,EAAC,MAAM,8BAA8B,CAAA;AAEpE,cAAc,8BAA8B,CAAA;AAE5C,MAAM,CAAC,IAAI,gBAAyC,CAAA;AAEpD,4FAA4F;AAC5F,IAAI,UAAU,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAC;IACjC,gBAAgB,GAAG,UAAU,CAAC,gBAAgB,GAAG,IAAI,uBAAuB,CAAC,QAAQ,CAAC,CAAA;IAEtF,MAAM,oBAAoB,GAAG,OAAO,CAAC,SAAS,CAAC,YAAY,CAAA;IAE3D,OAAO,CAAC,SAAS,CAAC,YAAY,GAAG,SAAS,YAAY,CAAC,OAAO;QAC7D,MAAM,IAAI,GAAG,oBAAoB,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;QAErD,IAAI,CAAC,IAAI,CAAC,gBAAgB;YAAE,IAAI,CAAC,gBAAgB,GAAG,IAAI,uBAAuB,CAAC,IAAI,CAAC,CAAA;QAErF,OAAO,IAAI,CAAA;IACZ,CAAC,CAAA;AACF,CAAC;AAmBD,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAA"} -------------------------------------------------------------------------------- /dist/CustomAttributeRegistry.d.ts: -------------------------------------------------------------------------------- 1 | import type { Constructor } from 'lowclass/dist/Constructor.js'; 2 | export declare class CustomAttributeRegistry { 3 | #private; 4 | ownerDocument: Document | ShadowRoot; 5 | constructor(ownerDocument: Document | ShadowRoot); 6 | define(attrName: string, Class: Constructor): void; 7 | get(element: Element, attrName: string): CustomAttribute | undefined; 8 | } 9 | export interface CustomAttribute { 10 | ownerElement: Element; 11 | name: string; 12 | value: string; 13 | connectedCallback?(): void; 14 | disconnectedCallback?(): void; 15 | changedCallback?(oldValue: string, newValue: string): void; 16 | } 17 | //# sourceMappingURL=CustomAttributeRegistry.d.ts.map -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | // TODO We don't know when a ShadowRoot is no longer referenced, hence we cannot 2 | // unobserve them. Verify that MOs are cleaned up once ShadowRoots are no longer 3 | // referenced. 4 | import { CustomAttributeRegistry } from './CustomAttributeRegistry.js'; 5 | export * from './CustomAttributeRegistry.js'; 6 | export let customAttributes; 7 | // Avoid errors trying to use DOM APIs in non-DOM environments (f.e. server-side rendering). 8 | if (globalThis.window?.document) { 9 | customAttributes = globalThis.customAttributes = new CustomAttributeRegistry(document); 10 | const originalAttachShadow = Element.prototype.attachShadow; 11 | Element.prototype.attachShadow = function attachShadow(options) { 12 | const root = originalAttachShadow.call(this, options); 13 | if (!root.customAttributes) 14 | root.customAttributes = new CustomAttributeRegistry(root); 15 | return root; 16 | }; 17 | } 18 | export const version = '0.3.0'; 19 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /src/test/change.test.ts: -------------------------------------------------------------------------------- 1 | import '../index.js' 2 | 3 | describe('changedCallback()', function () { 4 | async function later() { 5 | await new Promise(r => setTimeout(r, 4)) 6 | } 7 | 8 | it('Is called when an attribute changes value', async function () { 9 | const {resolve, promise: changedPromise} = Promise.withResolvers() 10 | 11 | class SomeAttr { 12 | changedCallback() { 13 | resolve() 14 | } 15 | } 16 | 17 | customAttributes.define('some-attr', SomeAttr) 18 | 19 | const el = document.createElement('article') 20 | el.setAttribute('some-attr', 'foo') 21 | document.body.append(el) 22 | 23 | queueMicrotask(() => el.setAttribute('some-attr', 'bar')) 24 | 25 | await changedPromise 26 | 27 | el.remove() 28 | }) 29 | 30 | it("Is not called when an attribute's value remains the same", async function () { 31 | let count = 0 32 | 33 | class AnotherAttr { 34 | changedCallback() { 35 | count++ 36 | } 37 | } 38 | 39 | customAttributes.define('another-attr', AnotherAttr) 40 | 41 | const el = document.createElement('span') 42 | el.setAttribute('another-attr', 'foo') 43 | document.body.append(el) 44 | 45 | el.setAttribute('another-attr', 'foo') 46 | 47 | await later() 48 | 49 | expect(count).toBe(0) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.operating-system }} 8 | 9 | strategy: 10 | matrix: 11 | operating-system: [ubuntu-latest, windows-latest, macos-latest] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js latest 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: latest 19 | - name: install 20 | run: | 21 | npm i 22 | - name: check formatting 23 | run: | 24 | npm run prettier:check 25 | - name: build 26 | run: | 27 | npm run clean 28 | npm run build 29 | - name: test 30 | run: | 31 | npm test 32 | - name: check repo is clean 33 | # skip this check in windows for now, as the build outputs may get slightly modified in Windows, which we want to fix. 34 | if: runner.os != 'Windows' 35 | run: | 36 | git add . && git diff --quiet && git diff --cached --quiet 37 | env: 38 | CI: true 39 | -------------------------------------------------------------------------------- /dist/test/change.test.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"change.test.js","sourceRoot":"","sources":["../../src/test/change.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,QAAQ,CAAC,mBAAmB,EAAE;IAC7B,KAAK,UAAU,KAAK;QACnB,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IACzC,CAAC;IAED,EAAE,CAAC,2CAA2C,EAAE,KAAK;QACpD,MAAM,EAAC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAC,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAA;QAExE,MAAM,QAAQ;YACb,eAAe;gBACd,OAAO,EAAE,CAAA;YACV,CAAC;SACD;QAED,gBAAgB,CAAC,MAAM,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;QAE9C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,SAAS,CAAC,CAAA;QAC5C,EAAE,CAAC,YAAY,CAAC,WAAW,EAAE,KAAK,CAAC,CAAA;QACnC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,cAAc,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,YAAY,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,CAAA;QAEzD,MAAM,cAAc,CAAA;QAEpB,EAAE,CAAC,MAAM,EAAE,CAAA;IACZ,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,0DAA0D,EAAE,KAAK;QACnE,IAAI,KAAK,GAAG,CAAC,CAAA;QAEb,MAAM,WAAW;YAChB,eAAe;gBACd,KAAK,EAAE,CAAA;YACR,CAAC;SACD;QAED,gBAAgB,CAAC,MAAM,CAAC,cAAc,EAAE,WAAW,CAAC,CAAA;QAEpD,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QACzC,EAAE,CAAC,YAAY,CAAC,cAAc,EAAE,KAAK,CAAC,CAAA;QACtC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,EAAE,CAAC,YAAY,CAAC,cAAc,EAAE,KAAK,CAAC,CAAA;QAEtC,MAAM,KAAK,EAAE,CAAA;QAEb,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACtB,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"} -------------------------------------------------------------------------------- /src/test/get.test.ts: -------------------------------------------------------------------------------- 1 | import '../index.js' 2 | 3 | describe('get()', function () { 4 | async function later() { 5 | await new Promise(r => setTimeout(r, 4)) 6 | } 7 | 8 | it('gets the attribute instance', async function () { 9 | class Foobar {} 10 | customAttributes.define('foo-bar', Foobar) 11 | 12 | const el = document.createElement('span') 13 | el.setAttribute('foo-bar', 'baz') 14 | document.body.append(el) 15 | 16 | await later() 17 | 18 | const fooBar = customAttributes.get(el, 'foo-bar') 19 | expect(fooBar).toBeInstanceOf(Foobar) 20 | }) 21 | 22 | it('allows passing more complex data types', async function () { 23 | const {resolve, promise: bazSetPromise} = Promise.withResolvers() 24 | 25 | class Foobar { 26 | set baz(val: any) { 27 | expect(val).toBe('hello world') 28 | resolve() 29 | } 30 | } 31 | 32 | customAttributes.define('foo-bar', Foobar) 33 | 34 | const el = document.createElement('span') 35 | el.setAttribute('foo-bar', 'bam') 36 | document.body.append(el) 37 | 38 | await later() 39 | 40 | const fooBar = customAttributes.get(el, 'foo-bar') 41 | 42 | queueMicrotask( 43 | () => 44 | // @ts-expect-error TODO make a CustomAttributes map for registering global attribute types. 45 | (fooBar!.baz = 'hello world'), 46 | ) 47 | 48 | await bazSetPromise 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /dist/test/get.test.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"get.test.js","sourceRoot":"","sources":["../../src/test/get.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,QAAQ,CAAC,OAAO,EAAE;IACjB,KAAK,UAAU,KAAK;QACnB,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IACzC,CAAC;IAED,EAAE,CAAC,6BAA6B,EAAE,KAAK;QACtC,MAAM,MAAM;SAAG;QACf,gBAAgB,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;QAE1C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QACzC,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;QACjC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,MAAM,KAAK,EAAE,CAAA;QAEb,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,EAAE,EAAE,SAAS,CAAC,CAAA;QAClD,MAAM,CAAC,MAAM,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,CAAA;IACtC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,wCAAwC,EAAE,KAAK;QACjD,MAAM,EAAC,OAAO,EAAE,OAAO,EAAE,aAAa,EAAC,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAA;QAEvE,MAAM,MAAM;YACX,IAAI,GAAG,CAAC,GAAQ;gBACf,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;gBAC/B,OAAO,EAAE,CAAA;YACV,CAAC;SACD;QAED,gBAAgB,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;QAE1C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QACzC,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;QACjC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,MAAM,KAAK,EAAE,CAAA;QAEb,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,EAAE,EAAE,SAAS,CAAC,CAAA;QAElD,cAAc,CACb,GAAG,EAAE;QACJ,4FAA4F;QAC5F,CAAC,MAAO,CAAC,GAAG,GAAG,aAAa,CAAC,CAC9B,CAAA;QAED,MAAM,aAAa,CAAA;IACpB,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/test/change.test.js: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | describe('changedCallback()', function () { 3 | async function later() { 4 | await new Promise(r => setTimeout(r, 4)); 5 | } 6 | it('Is called when an attribute changes value', async function () { 7 | const { resolve, promise: changedPromise } = Promise.withResolvers(); 8 | class SomeAttr { 9 | changedCallback() { 10 | resolve(); 11 | } 12 | } 13 | customAttributes.define('some-attr', SomeAttr); 14 | const el = document.createElement('article'); 15 | el.setAttribute('some-attr', 'foo'); 16 | document.body.append(el); 17 | queueMicrotask(() => el.setAttribute('some-attr', 'bar')); 18 | await changedPromise; 19 | el.remove(); 20 | }); 21 | it("Is not called when an attribute's value remains the same", async function () { 22 | let count = 0; 23 | class AnotherAttr { 24 | changedCallback() { 25 | count++; 26 | } 27 | } 28 | customAttributes.define('another-attr', AnotherAttr); 29 | const el = document.createElement('span'); 30 | el.setAttribute('another-attr', 'foo'); 31 | document.body.append(el); 32 | el.setAttribute('another-attr', 'foo'); 33 | await later(); 34 | expect(count).toBe(0); 35 | }); 36 | }); 37 | //# sourceMappingURL=change.test.js.map -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lume/custom-attributes", 3 | "version": "0.3.0", 4 | "description": "Custom attributes: like custom elements, but for attributes", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "type": "module", 8 | "scripts": { 9 | "LUME SCRIPTS XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX": "", 10 | "clean": "lume clean", 11 | "build": "lume build", 12 | "dev": "lume dev", 13 | "typecheck": "lume typecheck", 14 | "typecheck:watch": "lume typecheckWatch", 15 | "test": "lume test", 16 | "test:watch": "lume test --watch", 17 | "prettier": "lume prettier", 18 | "prettier:check": "lume prettierCheck", 19 | "release:patch": "lume releasePatch", 20 | "release:minor": "lume releaseMinor", 21 | "release:major": "lume releaseMajor", 22 | "version": "lume versionHook", 23 | "postversion": "lume postVersionHook" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/lume/custom-attributes.git" 28 | }, 29 | "keywords": [ 30 | "web", 31 | "components" 32 | ], 33 | "author": "Matthew Phillips", 34 | "license": "BSD-2-Clause", 35 | "bugs": { 36 | "url": "https://github.com/lume/custom-attributes/issues" 37 | }, 38 | "homepage": "https://github.com/lume/custom-attributes#readme", 39 | "devDependencies": { 40 | "@lume/cli": "^0.14.0", 41 | "prettier": "3.0.3", 42 | "rollup": "^0.41.4", 43 | "typescript": "^5.0.0" 44 | }, 45 | "dependencies": { 46 | "lowclass": "^8.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /dist/test/get.test.js: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | describe('get()', function () { 3 | async function later() { 4 | await new Promise(r => setTimeout(r, 4)); 5 | } 6 | it('gets the attribute instance', async function () { 7 | class Foobar { 8 | } 9 | customAttributes.define('foo-bar', Foobar); 10 | const el = document.createElement('span'); 11 | el.setAttribute('foo-bar', 'baz'); 12 | document.body.append(el); 13 | await later(); 14 | const fooBar = customAttributes.get(el, 'foo-bar'); 15 | expect(fooBar).toBeInstanceOf(Foobar); 16 | }); 17 | it('allows passing more complex data types', async function () { 18 | const { resolve, promise: bazSetPromise } = Promise.withResolvers(); 19 | class Foobar { 20 | set baz(val) { 21 | expect(val).toBe('hello world'); 22 | resolve(); 23 | } 24 | } 25 | customAttributes.define('foo-bar', Foobar); 26 | const el = document.createElement('span'); 27 | el.setAttribute('foo-bar', 'bam'); 28 | document.body.append(el); 29 | await later(); 30 | const fooBar = customAttributes.get(el, 'foo-bar'); 31 | queueMicrotask(() => 32 | // @ts-expect-error TODO make a CustomAttributes map for registering global attribute types. 33 | (fooBar.baz = 'hello world')); 34 | await bazSetPromise; 35 | }); 36 | }); 37 | //# sourceMappingURL=get.test.js.map -------------------------------------------------------------------------------- /dist/test/disconnect.test.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"disconnect.test.js","sourceRoot":"","sources":["../../src/test/disconnect.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,QAAQ,CAAC,wBAAwB,EAAE;IAClC,KAAK,UAAU,KAAK;QACnB,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IACzC,CAAC;IAED,EAAE,CAAC,0CAA0C,EAAE,KAAK;QACnD,MAAM,EAAC,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAC,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAA;QAE7E,IAAI,KAAK,GAAG,CAAC,CAAA;QAEb,MAAM,MAAM;YACX,KAAK,CAAC,iBAAiB;gBACtB,MAAM,KAAK,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAA;YACxD,CAAC;YAED,eAAe;gBACd,KAAK,EAAE,CAAA;YACR,CAAC;YAED,oBAAoB;gBACnB,OAAO,EAAE,CAAA;YACV,CAAC;SACD;QAED,gBAAgB,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;QAE1C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QACzC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,cAAc,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAAA;QAExD,MAAM,mBAAmB,CAAA;QAEzB,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACtB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,uCAAuC,EAAE,KAAK;QAChD,MAAM,EAAC,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAC,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAA;QAE7E,MAAM,QAAQ;YACb,KAAK,CAAC,iBAAiB;gBACtB,MAAM,KAAK,EAAE,CAAA;gBAEb,EAAE,CAAC,MAAM,EAAE,CAAA;YACZ,CAAC;YAED,oBAAoB;gBACnB,OAAO,EAAE,CAAA;YACV,CAAC;SACD;QAED,gBAAgB,CAAC,MAAM,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;QAE9C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAA;QACzC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAExB,cAAc,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAA;QAE1D,MAAM,mBAAmB,CAAA;IAC1B,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"} -------------------------------------------------------------------------------- /src/test/disconnect.test.ts: -------------------------------------------------------------------------------- 1 | import '../index.js' 2 | 3 | describe('disconnectedCallback()', function () { 4 | async function later() { 5 | await new Promise(r => setTimeout(r, 4)) 6 | } 7 | 8 | it('Is called when removeAttribute() is used', async function () { 9 | const {resolve, promise: disconnectedPromise} = Promise.withResolvers() 10 | 11 | let count = 0 12 | 13 | class MyAttr { 14 | async connectedCallback() { 15 | await later().then(() => el.removeAttribute('my-attr')) 16 | } 17 | 18 | changedCallback() { 19 | count++ 20 | } 21 | 22 | disconnectedCallback() { 23 | resolve() 24 | } 25 | } 26 | 27 | customAttributes.define('my-attr', MyAttr) 28 | 29 | const el = document.createElement('span') 30 | document.body.append(el) 31 | 32 | queueMicrotask(() => el.setAttribute('my-attr', 'test')) 33 | 34 | await disconnectedPromise 35 | 36 | expect(count).toBe(0) 37 | }) 38 | 39 | it('Is called when the element is removed', async function () { 40 | const {resolve, promise: disconnectedPromise} = Promise.withResolvers() 41 | 42 | class SomeAttr { 43 | async connectedCallback() { 44 | await later() 45 | 46 | el.remove() 47 | } 48 | 49 | disconnectedCallback() { 50 | resolve() 51 | } 52 | } 53 | 54 | customAttributes.define('some-attr', SomeAttr) 55 | 56 | const el = document.createElement('span') 57 | document.body.append(el) 58 | 59 | queueMicrotask(() => el.setAttribute('some-attr', 'test')) 60 | 61 | await disconnectedPromise 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /dist/test/connect.test.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"connect.test.js","sourceRoot":"","sources":["../../src/test/connect.test.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAMpB,MAAM,WAAW;IAIhB,iBAAiB;QAChB,IAAI,CAAC,QAAQ,EAAE,CAAA;IAChB,CAAC;IAED,eAAe,CAAC,SAAc,EAAE,SAAc;QAC7C,IAAI,CAAC,QAAQ,EAAE,CAAA;IAChB,CAAC;IAED,QAAQ;QACP,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAA;QAC9B,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,eAAe,GAAG,KAAK,CAAA;IAChD,CAAC;CACD;AAED,QAAQ,CAAC,IAAI,CAAC,kBAAkB,CAC/B,WAAW;AACX,QAAQ,CAAC;;;;CAIT,CACA,CAAA;AAED,gBAAgB,CAAC,MAAM,CAAC,UAAU,EAAE,WAAW,CAAC,CAAA;AAEhD,QAAQ,CAAC,qBAAqB,EAAE;IAC/B,EAAE,CAAC,8CAA8C,EAAE;QAClD,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,SAAS,CAAE,CAAA;QAClD,MAAM,CAAC,OAAQ,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAClD,OAAO,CAAC,MAAM,EAAE,CAAA;IACjB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,KAAK;QAC7D,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,SAAS,CAAC,CAAA;QACjD,OAAO,CAAC,WAAW,GAAG,aAAa,CAAA;QACnC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAE7B,OAAO,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QAExC,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;QAExC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAClD,OAAO,CAAC,MAAM,EAAE,CAAA;IACjB,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oEAAoE,EAAE,KAAK;QAC7E,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAa,CAAC,SAAS,CAAC,CAAA;QACjD,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QAE/C,MAAM,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QACvC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QAEtB,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QAE7B,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;QAExC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACjD,OAAO,CAAC,MAAM,EAAE,CAAA;IACjB,CAAC,CAAC,CAAA;AACH,CAAC,CAAC,CAAA"} -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // TODO We don't know when a ShadowRoot is no longer referenced, hence we cannot 2 | // unobserve them. Verify that MOs are cleaned up once ShadowRoots are no longer 3 | // referenced. 4 | 5 | import {CustomAttributeRegistry} from './CustomAttributeRegistry.js' 6 | 7 | export * from './CustomAttributeRegistry.js' 8 | 9 | export let customAttributes: CustomAttributeRegistry 10 | 11 | // Avoid errors trying to use DOM APIs in non-DOM environments (f.e. server-side rendering). 12 | if (globalThis.window?.document) { 13 | customAttributes = globalThis.customAttributes = new CustomAttributeRegistry(document) 14 | 15 | const originalAttachShadow = Element.prototype.attachShadow 16 | 17 | Element.prototype.attachShadow = function attachShadow(options) { 18 | const root = originalAttachShadow.call(this, options) 19 | 20 | if (!root.customAttributes) root.customAttributes = new CustomAttributeRegistry(root) 21 | 22 | return root 23 | } 24 | } 25 | 26 | declare global { 27 | // const doesn't always work (TS bug). At time of writing this, it doesn't work in this TS playground example: 28 | // https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBAbzgXwFCoCbAMYBsCGUwcA5rhAEb66KpxzYQB2AzvAGYQQBccTArgFsKwKKjSoylagBUAFgEsWAOk4Q4Aeg1wAolCjQANHAXwYipgGsWcAAZrbJm0wjws7BU2AYgA 29 | // And discussions: 30 | // https://discord.com/channels/508357248330760243/508357248330760249/1019034094060978228 31 | // https://discord.com/channels/508357248330760243/1019017621397585961 32 | var customAttributes: CustomAttributeRegistry 33 | 34 | interface ShadowRoot { 35 | customAttributes: CustomAttributeRegistry 36 | } 37 | 38 | interface Window { 39 | customAttributes: CustomAttributeRegistry 40 | } 41 | } 42 | 43 | export const version = '0.3.0' 44 | -------------------------------------------------------------------------------- /dist/test/disconnect.test.js: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | describe('disconnectedCallback()', function () { 3 | async function later() { 4 | await new Promise(r => setTimeout(r, 4)); 5 | } 6 | it('Is called when removeAttribute() is used', async function () { 7 | const { resolve, promise: disconnectedPromise } = Promise.withResolvers(); 8 | let count = 0; 9 | class MyAttr { 10 | async connectedCallback() { 11 | await later().then(() => el.removeAttribute('my-attr')); 12 | } 13 | changedCallback() { 14 | count++; 15 | } 16 | disconnectedCallback() { 17 | resolve(); 18 | } 19 | } 20 | customAttributes.define('my-attr', MyAttr); 21 | const el = document.createElement('span'); 22 | document.body.append(el); 23 | queueMicrotask(() => el.setAttribute('my-attr', 'test')); 24 | await disconnectedPromise; 25 | expect(count).toBe(0); 26 | }); 27 | it('Is called when the element is removed', async function () { 28 | const { resolve, promise: disconnectedPromise } = Promise.withResolvers(); 29 | class SomeAttr { 30 | async connectedCallback() { 31 | await later(); 32 | el.remove(); 33 | } 34 | disconnectedCallback() { 35 | resolve(); 36 | } 37 | } 38 | customAttributes.define('some-attr', SomeAttr); 39 | const el = document.createElement('span'); 40 | document.body.append(el); 41 | queueMicrotask(() => el.setAttribute('some-attr', 'test')); 42 | await disconnectedPromise; 43 | }); 44 | }); 45 | //# sourceMappingURL=disconnect.test.js.map -------------------------------------------------------------------------------- /dist/test/connect.test.js: -------------------------------------------------------------------------------- 1 | import '../index.js'; 2 | class BgColorAttr { 3 | connectedCallback() { 4 | this.setColor(); 5 | } 6 | changedCallback(_oldValue, _newValue) { 7 | this.setColor(); 8 | } 9 | setColor() { 10 | const color = this.value || ''; 11 | this.ownerElement.style.backgroundColor = color; 12 | } 13 | } 14 | document.body.insertAdjacentHTML('beforeend', 15 | /*html*/ ` 16 |
17 |

This article is static

18 |
19 | `); 20 | customAttributes.define('bg-color', BgColorAttr); 21 | describe('connectedCallback()', function () { 22 | it('Is called when element is already in the DOM', function () { 23 | const article = document.querySelector('article'); 24 | expect(article.style.backgroundColor).toBe('red'); 25 | article.remove(); 26 | }); 27 | it('Is called when an attribute is dynamically created', async function () { 28 | const article = document.createElement('article'); 29 | article.textContent = 'hello world'; 30 | document.body.append(article); 31 | article.setAttribute('bg-color', 'blue'); 32 | await new Promise(r => setTimeout(r, 4)); 33 | expect(article.style.backgroundColor).toBe('blue'); 34 | article.remove(); 35 | }); 36 | it('Is called on nested elements when root element is added to the DOM', async function () { 37 | const article = document.createElement('article'); 38 | const header = document.createElement('header'); 39 | header.setAttribute('bg-color', 'blue'); 40 | article.append(header); 41 | document.body.append(article); 42 | await new Promise(r => setTimeout(r, 4)); 43 | expect(header.style.backgroundColor).toBe('blue'); 44 | article.remove(); 45 | }); 46 | }); 47 | //# sourceMappingURL=connect.test.js.map -------------------------------------------------------------------------------- /src/test/connect.test.ts: -------------------------------------------------------------------------------- 1 | import '../index.js' 2 | 3 | declare global { 4 | function expect(...args: any[]): any 5 | } 6 | 7 | class BgColorAttr { 8 | declare value: any 9 | declare ownerElement: any 10 | 11 | connectedCallback() { 12 | this.setColor() 13 | } 14 | 15 | changedCallback(_oldValue: any, _newValue: any) { 16 | this.setColor() 17 | } 18 | 19 | setColor() { 20 | const color = this.value || '' 21 | this.ownerElement.style.backgroundColor = color 22 | } 23 | } 24 | 25 | document.body.insertAdjacentHTML( 26 | 'beforeend', 27 | /*html*/ ` 28 |
29 |

This article is static

30 |
31 | `, 32 | ) 33 | 34 | customAttributes.define('bg-color', BgColorAttr) 35 | 36 | describe('connectedCallback()', function () { 37 | it('Is called when element is already in the DOM', function () { 38 | const article = document.querySelector('article')! 39 | expect(article!.style.backgroundColor).toBe('red') 40 | article.remove() 41 | }) 42 | 43 | it('Is called when an attribute is dynamically created', async function () { 44 | const article = document.createElement('article') 45 | article.textContent = 'hello world' 46 | document.body.append(article) 47 | 48 | article.setAttribute('bg-color', 'blue') 49 | 50 | await new Promise(r => setTimeout(r, 4)) 51 | 52 | expect(article.style.backgroundColor).toBe('blue') 53 | article.remove() 54 | }) 55 | 56 | it('Is called on nested elements when root element is added to the DOM', async function () { 57 | const article = document.createElement('article') 58 | const header = document.createElement('header') 59 | 60 | header.setAttribute('bg-color', 'blue') 61 | article.append(header) 62 | 63 | document.body.append(article) 64 | 65 | await new Promise(r => setTimeout(r, 4)) 66 | 67 | expect(header.style.backgroundColor).toBe('blue') 68 | article.remove() 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /dist/CustomAttributeRegistry.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"CustomAttributeRegistry.js","sourceRoot":"","sources":["../src/CustomAttributeRegistry.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://github.com/lume/custom-attributes/actions/workflows/tests.yml/badge.svg)](https://github.com/lume/custom-attributes/actions/workflows/tests.yml) 2 | [![npm version](https://badge.fury.io/js/custom-attributes.svg)](http://badge.fury.io/js/custom-attributes) 3 | 4 | # custom-attributes 5 | 6 | Define custom attributes in the same way you can define custom elements, which allows for rich mixin types of behaviors on elements. 7 | 8 | ## Spec duscussions 9 | 10 | The idea of "custom attributes" (and similar ideas) are being discussed as possibilities for web specs. 11 | 12 | Existing conversations (please notify if this needs an update): 13 | 14 | - Element Behaviors, and the has="" attribute. A useful alternative to Custom Elements in many cases! WICG/webcomponents#727 15 | - Alternative to customized builtins (custom elements with is=) w3c/tpac2023-breakouts#44 16 | - Proposal: Custom attributes for all elements, enhancements for more complex use cases WICG/webcomponents#1029 17 | - Customized built-in elements WebKit/standards-positions#97 18 | - Make custom attribute rules consistent with custom element name rules whatwg/html#2271 19 | 20 | ## Install 21 | 22 | ```shell 23 | npm install @lume/custom-attributes --save 24 | ``` 25 | 26 | Add as a script tag: 27 | 28 | ```html 29 | 30 | ``` 31 | 32 | Or import as an ES module: 33 | 34 | ```js 35 | import {customAttributes} from '@lume/custom-attributes' 36 | ``` 37 | 38 | Or you can just import the CustomAttributeRegistry and create your own instance: 39 | 40 | ```js 41 | import {CustomAttributeRegistry} from '@lume/custom-attributes' 42 | 43 | const customAttributes = new CustomAttributeRegistry(document) 44 | ``` 45 | 46 | ## Example 47 | 48 | ```html 49 |
50 |

This will be shown in a green background!

51 |
52 | ``` 53 | 54 | ```js 55 | class BgColor { 56 | connectedCallback() { 57 | this.setColor() 58 | } 59 | 60 | disconnectedCallback() { 61 | // cleanup here! 62 | } 63 | 64 | // Called whenever the attribute's value changes 65 | changedCallback() { 66 | this.setColor() 67 | } 68 | 69 | setColor() { 70 | this.ownerElement.style.backgroundColor = this.value 71 | } 72 | } 73 | 74 | customAttributes.default.define('bg-color', BgColor) 75 | ``` 76 | 77 | ## API 78 | 79 | custom-attributes follows a very similar API as v1 custom elements, but rather than a class instance representing the host element, the class instance is meant to represent the _attribute_. 80 | 81 | ### Lifecycle callbacks 82 | 83 | #### connectedCallback 84 | 85 | This is called when the attribute is first connected to the `document`. If the host element is already in the DOM, and the attribute is set, connectedCallback will be called as all registered attributes are upgraded. 86 | 87 | If the host element is already in the DOM and the attribute is programmatically added via `setAttribute`, then connectedCallback will be called asynchronously after. 88 | 89 | If the host element is being programmatically created and the attribute is set before the element is inserted into the DOM, connectedCallback will only call when the host element is inserted. 90 | 91 | #### disconnectedCallback 92 | 93 | Called when the attribute is no longer part of the host element, or the host document. This callback should be used if any cleanup is needed. 94 | 95 | If the attribute is removed via `removeAttribute`, then disconnectedCallback will be called asynchronously after this change. If the host element is removed from the DOM, disconnectedCallback will be called asynchronously after as well. 96 | 97 | #### changedCallback 98 | 99 | Called any time the attribute's `value` changes, after connected. Useful if you need to perform work based on the attribute value such as the example given in this readme. 100 | 101 | ### Properties 102 | 103 | #### ownerElement 104 | 105 | `this.ownerElement` refers to the element which the attribute is attached. 106 | 107 | #### name 108 | 109 | The attribute's name. Since multiple class definitions can be used for multiple attribute names, `this.name` is useful if you need to know what the attribute is being referred to as. 110 | 111 | #### value 112 | 113 | The current value of the attribute (the string value) is available as `this.value`. 114 | 115 | ## License 116 | 117 | BSD 2 Clause 118 | -------------------------------------------------------------------------------- /src/CustomAttributeRegistry.ts: -------------------------------------------------------------------------------- 1 | import type {Constructor} from 'lowclass/dist/Constructor.js' 2 | 3 | const forEach = Array.prototype.forEach 4 | 5 | export class CustomAttributeRegistry { 6 | #attrMap = new Map() 7 | #elementMap = new WeakMap>() 8 | 9 | #observer: MutationObserver = new MutationObserver(mutations => { 10 | forEach.call(mutations, (m: MutationRecord) => { 11 | if (m.type === 'attributes') { 12 | const attr = this.#getConstructor(m.attributeName!) 13 | if (attr) this.#handleChange(m.attributeName!, m.target as Element, m.oldValue) 14 | } 15 | 16 | // chlidList 17 | else { 18 | forEach.call(m.removedNodes, this.#elementDisconnected) 19 | forEach.call(m.addedNodes, this.#elementConnected) 20 | } 21 | }) 22 | }) 23 | 24 | constructor(public ownerDocument: Document | ShadowRoot) { 25 | if (!ownerDocument) throw new Error('Must be given a document') 26 | } 27 | 28 | define(attrName: string, Class: Constructor) { 29 | this.#attrMap.set(attrName, Class) 30 | this.#upgradeAttr(attrName) 31 | this.#reobserve() 32 | } 33 | 34 | get(element: Element, attrName: string) { 35 | const map = this.#elementMap.get(element) 36 | if (!map) return 37 | return map.get(attrName) 38 | } 39 | 40 | #getConstructor(attrName: string) { 41 | return this.#attrMap.get(attrName) 42 | } 43 | 44 | #observe() { 45 | this.#observer.observe(this.ownerDocument, { 46 | childList: true, 47 | subtree: true, 48 | attributes: true, 49 | attributeOldValue: true, 50 | attributeFilter: Array.from(this.#attrMap.keys()), 51 | // attributeFilter: [...this.#attrMap.keys()], // Broken in Oculus 52 | // attributeFilter: this.#attrMap.keys(), // This works in Chrome, but TS complains, and not clear if it should work in all browsers yet: https://github.com/whatwg/dom/issues/1092 53 | }) 54 | } 55 | 56 | #unobserve() { 57 | this.#observer.disconnect() 58 | } 59 | 60 | #reobserve() { 61 | this.#unobserve() 62 | this.#observe() 63 | } 64 | 65 | #upgradeAttr(attrName: string, node: Element | Document | ShadowRoot = this.ownerDocument) { 66 | const matches = node.querySelectorAll('[' + attrName + ']') 67 | 68 | // Possibly create custom attributes that may be in the given 'node' tree. 69 | // Use a forEach as Edge doesn't support for...of on a NodeList 70 | forEach.call(matches, (element: Element) => this.#handleChange(attrName, element, null)) 71 | } 72 | 73 | #elementConnected = (element: Element) => { 74 | if (element.nodeType !== 1) return 75 | 76 | // For each of the connected element's attribute, possibly instantiate the custom attributes. 77 | // Use a forEach as Safari 10 doesn't support for...of on NamedNodeMap (attributes) 78 | forEach.call(element.attributes, (attr: Attr) => { 79 | if (this.#getConstructor(attr.name)) this.#handleChange(attr.name, element, null) 80 | }) 81 | 82 | // Possibly instantiate custom attributes that may be in the subtree of the connected element. 83 | this.#attrMap.forEach((_constructor, attr) => this.#upgradeAttr(attr, element)) 84 | } 85 | 86 | #elementDisconnected = (element: Element) => { 87 | const map = this.#elementMap.get(element) 88 | if (!map) return 89 | 90 | map.forEach(inst => inst.disconnectedCallback?.(), this) 91 | 92 | this.#elementMap.delete(element) 93 | } 94 | 95 | #handleChange(attrName: string, el: Element, oldVal: string | null) { 96 | let map = this.#elementMap.get(el) 97 | if (!map) this.#elementMap.set(el, (map = new Map())) 98 | 99 | let inst = map.get(attrName) 100 | const newVal = el.getAttribute(attrName) 101 | 102 | // Attribute is being created 103 | if (!inst) { 104 | const Constructor = this.#getConstructor(attrName)! 105 | inst = new Constructor() as CustomAttribute 106 | map.set(attrName, inst) 107 | inst.ownerElement = el 108 | inst.name = attrName 109 | if (newVal == null) throw new Error('Not possible!') 110 | inst.value = newVal 111 | inst.connectedCallback?.() 112 | return 113 | } 114 | 115 | // Attribute was removed 116 | if (newVal == null) { 117 | inst.disconnectedCallback?.() 118 | map.delete(attrName) 119 | } 120 | 121 | // Attribute changed 122 | else if (newVal !== inst.value) { 123 | inst.value = newVal 124 | if (oldVal == null) throw new Error('Not possible!') 125 | inst.changedCallback?.(oldVal, newVal) 126 | } 127 | } 128 | } 129 | 130 | // TODO Replace with a class that extends from `Attr` for alignment with the web platform? 131 | export interface CustomAttribute { 132 | ownerElement: Element 133 | name: string 134 | value: string 135 | connectedCallback?(): void 136 | disconnectedCallback?(): void 137 | changedCallback?(oldValue: string, newValue: string): void 138 | } 139 | -------------------------------------------------------------------------------- /dist/CustomAttributeRegistry.js: -------------------------------------------------------------------------------- 1 | const forEach = Array.prototype.forEach; 2 | export class CustomAttributeRegistry { 3 | ownerDocument; 4 | #attrMap = new Map(); 5 | #elementMap = new WeakMap(); 6 | #observer = new MutationObserver(mutations => { 7 | forEach.call(mutations, (m) => { 8 | if (m.type === 'attributes') { 9 | const attr = this.#getConstructor(m.attributeName); 10 | if (attr) 11 | this.#handleChange(m.attributeName, m.target, m.oldValue); 12 | } 13 | // chlidList 14 | else { 15 | forEach.call(m.removedNodes, this.#elementDisconnected); 16 | forEach.call(m.addedNodes, this.#elementConnected); 17 | } 18 | }); 19 | }); 20 | constructor(ownerDocument) { 21 | this.ownerDocument = ownerDocument; 22 | if (!ownerDocument) 23 | throw new Error('Must be given a document'); 24 | } 25 | define(attrName, Class) { 26 | this.#attrMap.set(attrName, Class); 27 | this.#upgradeAttr(attrName); 28 | this.#reobserve(); 29 | } 30 | get(element, attrName) { 31 | const map = this.#elementMap.get(element); 32 | if (!map) 33 | return; 34 | return map.get(attrName); 35 | } 36 | #getConstructor(attrName) { 37 | return this.#attrMap.get(attrName); 38 | } 39 | #observe() { 40 | this.#observer.observe(this.ownerDocument, { 41 | childList: true, 42 | subtree: true, 43 | attributes: true, 44 | attributeOldValue: true, 45 | attributeFilter: Array.from(this.#attrMap.keys()), 46 | // attributeFilter: [...this.#attrMap.keys()], // Broken in Oculus 47 | // attributeFilter: this.#attrMap.keys(), // This works in Chrome, but TS complains, and not clear if it should work in all browsers yet: https://github.com/whatwg/dom/issues/1092 48 | }); 49 | } 50 | #unobserve() { 51 | this.#observer.disconnect(); 52 | } 53 | #reobserve() { 54 | this.#unobserve(); 55 | this.#observe(); 56 | } 57 | #upgradeAttr(attrName, node = this.ownerDocument) { 58 | const matches = node.querySelectorAll('[' + attrName + ']'); 59 | // Possibly create custom attributes that may be in the given 'node' tree. 60 | // Use a forEach as Edge doesn't support for...of on a NodeList 61 | forEach.call(matches, (element) => this.#handleChange(attrName, element, null)); 62 | } 63 | #elementConnected = (element) => { 64 | if (element.nodeType !== 1) 65 | return; 66 | // For each of the connected element's attribute, possibly instantiate the custom attributes. 67 | // Use a forEach as Safari 10 doesn't support for...of on NamedNodeMap (attributes) 68 | forEach.call(element.attributes, (attr) => { 69 | if (this.#getConstructor(attr.name)) 70 | this.#handleChange(attr.name, element, null); 71 | }); 72 | // Possibly instantiate custom attributes that may be in the subtree of the connected element. 73 | this.#attrMap.forEach((_constructor, attr) => this.#upgradeAttr(attr, element)); 74 | }; 75 | #elementDisconnected = (element) => { 76 | const map = this.#elementMap.get(element); 77 | if (!map) 78 | return; 79 | map.forEach(inst => inst.disconnectedCallback?.(), this); 80 | this.#elementMap.delete(element); 81 | }; 82 | #handleChange(attrName, el, oldVal) { 83 | let map = this.#elementMap.get(el); 84 | if (!map) 85 | this.#elementMap.set(el, (map = new Map())); 86 | let inst = map.get(attrName); 87 | const newVal = el.getAttribute(attrName); 88 | // Attribute is being created 89 | if (!inst) { 90 | const Constructor = this.#getConstructor(attrName); 91 | inst = new Constructor(); 92 | map.set(attrName, inst); 93 | inst.ownerElement = el; 94 | inst.name = attrName; 95 | if (newVal == null) 96 | throw new Error('Not possible!'); 97 | inst.value = newVal; 98 | inst.connectedCallback?.(); 99 | return; 100 | } 101 | // Attribute was removed 102 | if (newVal == null) { 103 | inst.disconnectedCallback?.(); 104 | map.delete(attrName); 105 | } 106 | // Attribute changed 107 | else if (newVal !== inst.value) { 108 | inst.value = newVal; 109 | if (oldVal == null) 110 | throw new Error('Not possible!'); 111 | inst.changedCallback?.(oldVal, newVal); 112 | } 113 | } 114 | } 115 | //# sourceMappingURL=CustomAttributeRegistry.js.map --------------------------------------------------------------------------------