├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── docs ├── activeElement.md ├── bindInput.md ├── documentVisibility.md ├── elementSize.md ├── elementVisibility.md ├── head.md ├── index.md ├── itemSelection.md ├── keyBinding.md ├── localStorage.md ├── markdown.md ├── onLongPress.md ├── permissions.md ├── propertyHistory.md ├── sessionStorage.md ├── slot.md └── windowScroll.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── _internal │ ├── elementTracking.ts │ └── storage.ts ├── controllers │ ├── activeElement.ts │ ├── documentVisibility.ts │ ├── elementSize.ts │ ├── elementVisibility.ts │ ├── head.ts │ ├── itemSelection.ts │ ├── keyBinding.ts │ ├── localStorage.ts │ ├── markdown.ts │ ├── permissions.ts │ ├── propertyHistory.ts │ ├── sessionStorage.ts │ ├── slot.ts │ └── windowScroll.ts ├── directives │ ├── bindInput.ts │ └── onLongPress.ts ├── main.ts └── test │ ├── controllers │ ├── activeElement_test.ts │ ├── documentVisibility_test.ts │ ├── elementSize_test.ts │ ├── elementVisibility_test.ts │ ├── head_test.ts │ ├── itemSelection_test.ts │ ├── keyBinding_test.ts │ ├── localStorage_test.ts │ ├── markdown_test.ts │ ├── permissions_test.ts │ ├── propertyHistory_test.ts │ ├── sessionStorage_test.ts │ ├── slot_test.ts │ └── windowScroll_test.ts │ ├── directives │ ├── bindInput_test.ts │ └── onLongPress_test.ts │ └── util.ts ├── tsconfig.json └── web-test-runner.config.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_size = 2 6 | indent_style = space 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x, 23.x] 14 | fail-fast: false 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Use Node v${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install Dependencies 23 | run: npm ci 24 | - name: Lint 25 | run: npm run lint 26 | - name: Test 27 | run: npm run test 28 | - name: Report Test Coverage 29 | uses: coverallsapp/github-action@master 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release (npm) 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Setup Node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | - name: Install Dependencies 18 | run: npm ci 19 | - name: Lint 20 | run: npm run lint 21 | - name: Test 22 | run: npm test 23 | 24 | publish-npm: 25 | needs: build 26 | runs-on: ubuntu-latest 27 | permissions: 28 | id-token: write 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 22.x 34 | registry-url: 'https://registry.npmjs.org' 35 | cache: 'npm' 36 | - run: npm ci 37 | - run: npm run build 38 | - run: npm version ${TAG_NAME} --git-tag-version=false 39 | env: 40 | TAG_NAME: ${{ github.ref_name }} 41 | - run: npm publish --provenance --access public --tag next 42 | if: "github.event.release.prerelease" 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 45 | - run: npm publish --provenance --access public 46 | if: "!github.event.release.prerelease" 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | *.swp 4 | /coverage 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "printWidth": 80, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "none", 8 | "useTabs": false, 9 | "arrowParens": "always", 10 | "embeddedLanguageFormatting": "off" 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 James Garbutt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # relit 2 | 3 | relit (reactive lit) is a collection of utilities to provide various features 4 | as reactive state to [lit](https://lit.dev) components. 5 | 6 | DOM state, web APIs, property history and much more. 7 | 8 | ## Install 9 | 10 | To install `relit`, simply add it as a dependency to your lit project: 11 | 12 | ```sh 13 | npm i -S relit 14 | ``` 15 | 16 | ## Usage 17 | 18 | For usage, see the [docs](./docs/index.md) of each available utility. 19 | 20 | ## License 21 | 22 | MIT 23 | -------------------------------------------------------------------------------- /docs/activeElement.md: -------------------------------------------------------------------------------- 1 | # activeElement 2 | 3 | `activeElement` allows you to observe the current active element of a document. 4 | 5 | ## Usage 6 | 7 | ```ts 8 | class MyElement extends LitElement { 9 | constructor() { 10 | super(); 11 | 12 | this._activeElementCtrl = new ActiveElementController(this); 13 | } 14 | 15 | render() { 16 | return html` 17 | Active element is: ${this._activeElementCtrl.activeElement?.nodeName}. 18 | `; 19 | } 20 | } 21 | ``` 22 | 23 | ## Options 24 | 25 | An optional `doc` can be passed when constructing this controller, such that 26 | you can observe the `activeElement` of a shadow root or a different document: 27 | 28 | ```ts 29 | this._activeElementCtrl = new ActiveElementController(this, this.shadowRoot); 30 | ``` 31 | 32 | Each shadow root has its own active element, while the parent document will 33 | only see as far as the shadow boundary (i.e. the host element) in terms of 34 | its own `activeElement`. 35 | 36 | You can read more about this [here](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/activeElement). 37 | -------------------------------------------------------------------------------- /docs/bindInput.md: -------------------------------------------------------------------------------- 1 | # bindInput 2 | 3 | `bindInput` allows you to automatically bind a form input/control to a given 4 | property (i.e. two-way binding). 5 | 6 | ## Usage 7 | 8 | ```ts 9 | class MyElement extends LitElement { 10 | @property({type: String}) 11 | public name: string = 'Bob'; 12 | 13 | render() { 14 | return html` 15 | 19 | `; 20 | } 21 | } 22 | ``` 23 | 24 | This will automatically two-way bind the `input` element and the `name` 25 | property. 26 | 27 | The parameters (`bindInput(host, property)`) are as follows: 28 | 29 | - `host` - any object which has the specified `property` 30 | - `property` - the name of the property on `host` to bind 31 | 32 | ### By attribute/property 33 | 34 | You may also use this directive with an attribute or property binding: 35 | 36 | ```ts 37 | return html` 38 | 42 | `; 43 | ``` 44 | 45 | This may help with SSR, and will work the same way as the element binding 46 | usage. 47 | 48 | ## Supported elements 49 | 50 | The following elements are supported by this directive: 51 | 52 | - `input` 53 | - `select` 54 | - `textarea` 55 | - Compatible elements 56 | 57 | ### Events 58 | 59 | By default, we listen for the `change` event and the `input` event. 60 | 61 | ### `` 62 | 63 | Checkbox inputs will result in a boolean value: 64 | 65 | ```ts 66 | return html` 67 | 68 | `; 69 | ``` 70 | 71 | In this example, `this.isEnabled` will be `true` or `false` depending on the 72 | checked state of the input. 73 | 74 | ### ``, the value will be an array of the selected values. 79 | 80 | With a regular ` 282 | `; 283 | await element.updateComplete; 284 | 285 | const node = element.shadowRoot!.querySelector('textarea')!; 286 | 287 | simulateKeyPress(node, 'x'); 288 | 289 | assert.is(spy.called, false); 290 | }); 291 | 292 | test('ignores presses from inputs by default', async () => { 293 | const spy = hanbi.spy(); 294 | 295 | controller.bindKey('x', spy.handler); 296 | 297 | element.template = () => html` 298 | 299 | `; 300 | await element.updateComplete; 301 | 302 | const node = element.shadowRoot!.querySelector('input')!; 303 | 304 | simulateKeyPress(node, 'x'); 305 | 306 | assert.is(spy.called, false); 307 | }); 308 | 309 | test('ignores presses from selects by default', async () => { 310 | const spy = hanbi.spy(); 311 | 312 | controller.bindKey('x', spy.handler); 313 | 314 | element.template = () => html` 315 | 316 | `; 317 | await element.updateComplete; 318 | 319 | const node = element.shadowRoot!.querySelector('select')!; 320 | 321 | simulateKeyPress(node, 'x'); 322 | 323 | assert.is(spy.called, false); 324 | }); 325 | }); 326 | 327 | suite('options.ignoredElements', () => { 328 | setup(async () => { 329 | controller = new KeyBindingController(element, null, { 330 | ignoredElements: ['span', '.foo'] 331 | }); 332 | element.controllers.push(controller); 333 | document.body.appendChild(element); 334 | await element.updateComplete; 335 | }); 336 | 337 | test('ignores presses from named tags', async () => { 338 | const spy = hanbi.spy(); 339 | 340 | controller.bindKey('x', spy.handler); 341 | 342 | element.template = () => html` 343 | 344 | `; 345 | await element.updateComplete; 346 | 347 | const node = element.shadowRoot!.querySelector('span')!; 348 | 349 | simulateKeyPress(node, 'x'); 350 | 351 | assert.is(spy.called, false); 352 | }); 353 | 354 | test('ignores presses from tags by selector', async () => { 355 | const spy = hanbi.spy(); 356 | 357 | controller.bindKey('x', spy.handler); 358 | 359 | element.template = () => html` 360 |
361 | `; 362 | await element.updateComplete; 363 | 364 | const node = element.shadowRoot!.querySelector('div')!; 365 | 366 | simulateKeyPress(node, 'x'); 367 | 368 | assert.is(spy.called, false); 369 | }); 370 | }); 371 | 372 | suite('custom ref', () => { 373 | let elementRef: Ref; 374 | 375 | setup(async () => { 376 | elementRef = createRef(); 377 | controller = new KeyBindingController(element, elementRef); 378 | element.controllers.push(controller); 379 | element.template = () => html` 380 |
381 | Hello. 382 |
383 | `; 384 | document.body.appendChild(element); 385 | await element.updateComplete; 386 | }); 387 | 388 | test('calls function on key press in element', async () => { 389 | const spy = hanbi.spy(); 390 | 391 | controller.bindKey('x', spy.handler); 392 | 393 | simulateKeyPress(elementRef.value!, 'x'); 394 | 395 | assert.is(spy.called, true); 396 | 397 | const ev = spy.firstCall!.args[0]; 398 | 399 | assert.is(ev.type, 'keyup'); 400 | assert.is(spy.callCount, 1); 401 | }); 402 | 403 | test('ignores presses from unrelated elements', async () => { 404 | const spy = hanbi.spy(); 405 | 406 | controller.bindKey('x', spy.handler); 407 | 408 | const node = element.shadowRoot!.querySelector('span')!; 409 | 410 | simulateKeyPress(node, 'x'); 411 | 412 | assert.is(spy.called, false); 413 | }); 414 | }); 415 | }); 416 | -------------------------------------------------------------------------------- /src/test/controllers/localStorage_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import {LocalStorageController} from '../../main.js'; 6 | import type {TestElement} from '../util.js'; 7 | 8 | suite('LocalStorageController', () => { 9 | let element: TestElement; 10 | let controller: LocalStorageController<[number, number]>; 11 | 12 | teardown(() => { 13 | element.remove(); 14 | window.localStorage.removeItem('test-key'); 15 | }); 16 | 17 | suite('default', () => { 18 | setup(async () => { 19 | element = document.createElement('test-element') as TestElement; 20 | controller = new LocalStorageController(element, 'test-key'); 21 | element.controllers.push(controller); 22 | element.template = () => html`${JSON.stringify(controller.value)}`; 23 | document.body.appendChild(element); 24 | }); 25 | 26 | test('initialises to undefined', () => { 27 | assert.equal(controller.value, undefined); 28 | assert.equal(element.shadowRoot!.textContent, ''); 29 | }); 30 | 31 | suite('set value', () => { 32 | test('sets value in local storage', async () => { 33 | controller.value = [0, 0]; 34 | await element.updateComplete; 35 | 36 | const storageValue = window.localStorage.getItem('test-key'); 37 | 38 | assert.equal(controller.value, [0, 0]); 39 | assert.equal(element.shadowRoot!.textContent, '[0,0]'); 40 | assert.is(storageValue, '[0,0]'); 41 | }); 42 | 43 | test('removes from storage if value undefined', async () => { 44 | controller.value = [0, 0]; 45 | await element.updateComplete; 46 | 47 | controller.value = undefined; 48 | await element.updateComplete; 49 | 50 | const storageValue = window.localStorage.getItem('test-key'); 51 | 52 | assert.equal(controller.value, undefined); 53 | assert.equal(element.shadowRoot!.textContent, ''); 54 | assert.is(storageValue, null); 55 | }); 56 | 57 | test('updates other instances', async () => { 58 | const otherController = new LocalStorageController(element, 'test-key'); 59 | element.controllers.push(otherController); 60 | 61 | otherController.value = [1, 2]; 62 | await element.updateComplete; 63 | 64 | const storageValue = window.localStorage.getItem('test-key'); 65 | 66 | assert.equal(otherController.value, [1, 2]); 67 | assert.equal(element.shadowRoot!.textContent, '[1,2]'); 68 | assert.is(storageValue, '[1,2]'); 69 | }); 70 | }); 71 | 72 | test('reacts to storage events', async () => { 73 | window.localStorage.setItem('test-key', '[3,4]'); 74 | window.dispatchEvent( 75 | new StorageEvent('storage', { 76 | storageArea: window.localStorage, 77 | key: 'test-key' 78 | }) 79 | ); 80 | 81 | await element.updateComplete; 82 | 83 | assert.equal(controller.value, [3, 4]); 84 | assert.equal(element.shadowRoot!.textContent, '[3,4]'); 85 | }); 86 | }); 87 | 88 | suite('with default value', () => { 89 | setup(async () => { 90 | element = document.createElement('test-element') as TestElement; 91 | controller = new LocalStorageController(element, 'test-key', [5, 6]); 92 | element.controllers.push(controller); 93 | element.template = () => html`${JSON.stringify(controller.value)}`; 94 | document.body.appendChild(element); 95 | await element.updateComplete; 96 | }); 97 | 98 | test('initialises to default value', () => { 99 | const storageValue = window.localStorage.getItem('test-key'); 100 | 101 | assert.equal(controller.value, [5, 6]); 102 | assert.equal(element.shadowRoot!.textContent, '[5,6]'); 103 | assert.is(storageValue, '[5,6]'); 104 | }); 105 | }); 106 | 107 | suite('with saved value', () => { 108 | setup(() => { 109 | window.localStorage.setItem('test-key', JSON.stringify([7, 8])); 110 | element = document.createElement('test-element') as TestElement; 111 | element.template = () => html`${JSON.stringify(controller.value)}`; 112 | document.body.appendChild(element); 113 | }); 114 | 115 | test('initialises to saved value', async () => { 116 | controller = new LocalStorageController(element, 'test-key'); 117 | element.controllers.push(controller); 118 | await element.updateComplete; 119 | 120 | const storageValue = window.localStorage.getItem('test-key'); 121 | assert.equal(controller.value, [7, 8]); 122 | assert.equal(element.shadowRoot!.textContent, '[7,8]'); 123 | assert.is(storageValue, '[7,8]'); 124 | }); 125 | 126 | test('ignore default value and use saved value', async () => { 127 | controller = new LocalStorageController(element, 'test-key', [9, 10]); 128 | element.controllers.push(controller); 129 | await element.updateComplete; 130 | 131 | const storageValue = window.localStorage.getItem('test-key'); 132 | assert.equal(controller.value, [7, 8]); 133 | assert.equal(element.shadowRoot!.textContent, '[7,8]'); 134 | assert.is(storageValue, '[7,8]'); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/test/controllers/markdown_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as hanbi from 'hanbi'; 5 | import type {PropertyDeclarations} from 'lit'; 6 | import * as assert from 'uvu/assert'; 7 | import {MarkdownController, MarkdownOptions} from '../../main.js'; 8 | import {TestElementBase} from '../util.js'; 9 | 10 | /** 11 | * Test element for the markdown controller 12 | */ 13 | class MarkdownTestElement extends TestElementBase { 14 | public prop?: string; 15 | 16 | /** @inheritdoc */ 17 | public static override get properties(): PropertyDeclarations { 18 | return { 19 | prop: {type: String} 20 | }; 21 | } 22 | } 23 | 24 | customElements.define('markdown-test', MarkdownTestElement); 25 | 26 | suite('MarkdownController', () => { 27 | let element: MarkdownTestElement; 28 | let controller: MarkdownController; 29 | 30 | teardown(() => { 31 | element.remove(); 32 | hanbi.restore(); 33 | }); 34 | 35 | suite('default', () => { 36 | setup(async () => { 37 | element = document.createElement('markdown-test') as MarkdownTestElement; 38 | controller = new MarkdownController(element); 39 | element.controllers.push(controller); 40 | element.template = () => html`${controller.value}`; 41 | document.body.appendChild(element); 42 | await element.updateComplete; 43 | }); 44 | 45 | test('initialises to undefined', () => { 46 | assert.equal(controller.value, undefined); 47 | assert.equal(element.shadowRoot!.textContent, ''); 48 | }); 49 | 50 | suite('setValue', () => { 51 | test('updates markdown with new value', async () => { 52 | await controller.setValue('foo'); 53 | await element.updateComplete; 54 | 55 | assert.is.not(controller.value, undefined); 56 | assert.match(element.shadowRoot!.innerHTML, '

foo

'); 57 | }); 58 | 59 | test('leaves markdown untouched if last value unchanged', async () => { 60 | await controller.setValue('foo'); 61 | await element.updateComplete; 62 | 63 | const p = element.shadowRoot!.querySelector('p')!; 64 | 65 | await controller.setValue('foo'); 66 | await element.updateComplete; 67 | 68 | const newP = element.shadowRoot!.querySelector('p')!; 69 | 70 | assert.is(newP, p); 71 | assert.is.not(controller.value, undefined); 72 | assert.match(element.shadowRoot!.innerHTML, '

foo

'); 73 | }); 74 | }); 75 | 76 | suite('options', () => { 77 | setup(async () => { 78 | await controller.setValue('# foo'); 79 | await element.updateComplete; 80 | }); 81 | 82 | test('recomputes value if options changed', async () => { 83 | assert.match(element.shadowRoot!.innerHTML, '

foo

'); 84 | 85 | controller.options = { 86 | markedOptions: { 87 | headerIds: false 88 | } 89 | }; 90 | 91 | await element.updateComplete; 92 | 93 | assert.match(element.shadowRoot!.innerHTML, '

foo

'); 94 | }); 95 | 96 | test('does not recompute if options same', async () => { 97 | const spy = hanbi.spy(); 98 | const opts: MarkdownOptions<'prop'> = { 99 | markedOptions: { 100 | walkTokens: spy.handler 101 | } 102 | }; 103 | 104 | controller.options = opts; 105 | await element.updateComplete; 106 | 107 | assert.is(spy.callCount, 2); 108 | 109 | controller.options = opts; 110 | await element.updateComplete; 111 | 112 | assert.is(spy.callCount, 2); 113 | }); 114 | }); 115 | }); 116 | 117 | suite('options.property', () => { 118 | setup(async () => { 119 | element = document.createElement('markdown-test') as MarkdownTestElement; 120 | element.prop = 'foo'; 121 | controller = new MarkdownController(element, {property: 'prop'}); 122 | element.controllers.push(controller); 123 | element.template = () => html`${controller.value}`; 124 | document.body.appendChild(element); 125 | await element.updateComplete; 126 | }); 127 | 128 | test('initialises to current property value', () => { 129 | assert.is.not(controller.value, undefined); 130 | assert.match(element.shadowRoot!.innerHTML, '

foo

'); 131 | }); 132 | 133 | test('observes changes to property', async () => { 134 | element.prop = 'bar'; 135 | await element.updateComplete; 136 | 137 | assert.is.not(controller.value, undefined); 138 | assert.match(element.shadowRoot!.innerHTML, '

bar

'); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/test/controllers/permissions_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html, ReactiveController} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import * as hanbi from 'hanbi'; 6 | import {PermissionsController} from '../../main.js'; 7 | import type {TestElement} from '../util.js'; 8 | 9 | suite('PermissionsController', () => { 10 | let element: TestElement; 11 | let controller: PermissionsController; 12 | let permissionStub: hanbi.Stub; 13 | let eventSpy: hanbi.Stub<(name: string, handler: unknown) => void>; 14 | let mockStatus: PermissionStatus; 15 | let mockState: PermissionState; 16 | let permissionResolver: (state: PermissionStatus) => void; 17 | 18 | setup(async () => { 19 | eventSpy = hanbi.spy(); 20 | mockState = 'prompt'; 21 | mockStatus = { 22 | name: 'geolocation', 23 | addEventListener: eventSpy.handler, 24 | get state() { 25 | return mockState; 26 | } 27 | } as PermissionStatus; 28 | permissionStub = hanbi.stubMethod(navigator.permissions, 'query'); 29 | permissionStub.callsFake(() => { 30 | return new Promise((res) => { 31 | permissionResolver = res; 32 | }); 33 | }); 34 | element = document.createElement('test-element') as TestElement; 35 | controller = new PermissionsController(element, 'geolocation'); 36 | element.controllers.push(controller as ReactiveController); 37 | element.template = () => html`${controller.state}`; 38 | document.body.appendChild(element); 39 | await element.updateComplete; 40 | }); 41 | 42 | teardown(() => { 43 | element.remove(); 44 | hanbi.restore(); 45 | }); 46 | 47 | test('initialises to pending', () => { 48 | assert.equal(controller.state, 'pending'); 49 | assert.equal(element.shadowRoot!.textContent, 'pending'); 50 | }); 51 | 52 | test('changes from pending to prompt', async () => { 53 | permissionResolver(mockStatus); 54 | // TODO (43081j): be sure why two renders happen here 55 | await element.updateComplete; 56 | await element.updateComplete; 57 | assert.equal(controller.state, 'prompt'); 58 | assert.equal(element.shadowRoot!.textContent, 'prompt'); 59 | }); 60 | 61 | test('observes permission changes', async () => { 62 | permissionResolver(mockStatus); 63 | await element.updateComplete; 64 | 65 | const changeHandler = [...eventSpy.calls].find( 66 | (c) => c.args[0] === 'change' 67 | )!.args[1]; 68 | 69 | mockState = 'granted'; 70 | (changeHandler as () => void)(); 71 | 72 | await element.updateComplete; 73 | assert.equal(controller.state, 'granted'); 74 | assert.equal(element.shadowRoot!.textContent, 'granted'); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/test/controllers/propertyHistory_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html, PropertyDeclarations} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import {PropertyHistoryController} from '../../main.js'; 6 | import {TestElementBase} from '../util.js'; 7 | 8 | /** 9 | * Test element for the property history controller 10 | */ 11 | class PropertyHistoryTestElement extends TestElementBase { 12 | public prop?: string; 13 | 14 | /** @inheritdoc */ 15 | public static override get properties(): PropertyDeclarations { 16 | return { 17 | prop: {type: String} 18 | }; 19 | } 20 | } 21 | 22 | customElements.define('property-history-test', PropertyHistoryTestElement); 23 | 24 | suite('PropertyHistoryController', () => { 25 | let element: PropertyHistoryTestElement; 26 | let controller: PropertyHistoryController; 27 | 28 | setup(async () => { 29 | element = document.createElement( 30 | 'property-history-test' 31 | ) as PropertyHistoryTestElement; 32 | controller = new PropertyHistoryController(element, 'prop'); 33 | element.controllers.push(controller); 34 | element.template = () => html`Last: ${controller.lastChanged}`; 35 | document.body.appendChild(element); 36 | await element.updateComplete; 37 | }); 38 | 39 | teardown(() => { 40 | element.remove(); 41 | }); 42 | 43 | test('initialises with current value', () => { 44 | assert.is.not(controller.lastChanged, undefined); 45 | assert.is.not(element.shadowRoot!.textContent, 'Last: '); 46 | }); 47 | 48 | test('observes changes to property', async () => { 49 | const prevValue = controller.lastChanged; 50 | 51 | element.prop = 'foo'; 52 | await element.updateComplete; 53 | 54 | assert.is.not(controller.lastChanged, prevValue); 55 | assert.not.equal(element.shadowRoot!.textContent, 'Last: '); 56 | }); 57 | 58 | test('no value change if property value same', async () => { 59 | element.prop = 'foo'; 60 | await element.updateComplete; 61 | 62 | const prevValue = controller.lastChanged; 63 | 64 | element.prop = 'foo'; 65 | await element.updateComplete; 66 | 67 | assert.is(controller.lastChanged, prevValue); 68 | }); 69 | 70 | suite('undo', () => { 71 | test('can undo to previous value', async () => { 72 | element.prop = 'foo'; 73 | await element.updateComplete; 74 | 75 | const prevValue = controller.lastChanged; 76 | 77 | controller.undo(); 78 | await element.updateComplete; 79 | 80 | assert.equal(element.prop, undefined); 81 | assert.is.not(prevValue, controller.lastChanged); 82 | }); 83 | 84 | test('can undo multiple times', async () => { 85 | element.prop = 'foo'; 86 | await element.updateComplete; 87 | element.prop = 'bar'; 88 | await element.updateComplete; 89 | element.prop = 'baz'; 90 | await element.updateComplete; 91 | 92 | controller.undo(); 93 | await element.updateComplete; 94 | controller.undo(); 95 | await element.updateComplete; 96 | 97 | assert.equal(element.prop, 'foo'); 98 | }); 99 | 100 | test('cannot undo beyond start value', async () => { 101 | controller.undo(); 102 | await element.updateComplete; 103 | 104 | assert.equal(element.prop, undefined); 105 | }); 106 | }); 107 | 108 | suite('redo', () => { 109 | test('can redo to an undone value', async () => { 110 | element.prop = 'foo'; 111 | await element.updateComplete; 112 | 113 | controller.undo(); 114 | await element.updateComplete; 115 | 116 | const prevValue = controller.lastChanged; 117 | 118 | controller.redo(); 119 | await element.updateComplete; 120 | 121 | assert.equal(element.prop, 'foo'); 122 | assert.is.not(prevValue, controller.lastChanged); 123 | }); 124 | 125 | test('can redo multiple times', async () => { 126 | element.prop = 'foo'; 127 | await element.updateComplete; 128 | element.prop = 'bar'; 129 | await element.updateComplete; 130 | element.prop = 'baz'; 131 | await element.updateComplete; 132 | 133 | controller.undo(); // bar 134 | controller.undo(); // foo 135 | controller.redo(); // bar 136 | controller.redo(); // baz 137 | 138 | await element.updateComplete; 139 | 140 | assert.equal(element.prop, 'baz'); 141 | }); 142 | 143 | test('cannot redo beyond latest value', async () => { 144 | controller.redo(); 145 | await element.updateComplete; 146 | 147 | assert.equal(element.prop, undefined); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/test/controllers/sessionStorage_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import {SessionStorageController} from '../../main.js'; 6 | import type {TestElement} from '../util.js'; 7 | 8 | suite('SessionStorageController', () => { 9 | let element: TestElement; 10 | let controller: SessionStorageController<[number, number]>; 11 | 12 | teardown(() => { 13 | element.remove(); 14 | window.sessionStorage.removeItem('test-key'); 15 | }); 16 | 17 | suite('default', () => { 18 | setup(async () => { 19 | element = document.createElement('test-element') as TestElement; 20 | controller = new SessionStorageController(element, 'test-key'); 21 | element.controllers.push(controller); 22 | element.template = () => html`${JSON.stringify(controller.value)}`; 23 | document.body.appendChild(element); 24 | }); 25 | 26 | test('initialises to undefined', () => { 27 | assert.equal(controller.value, undefined); 28 | assert.equal(element.shadowRoot!.textContent, ''); 29 | }); 30 | 31 | suite('set value', () => { 32 | test('sets value in session storage', async () => { 33 | controller.value = [0, 0]; 34 | await element.updateComplete; 35 | 36 | const storageValue = window.sessionStorage.getItem('test-key'); 37 | 38 | assert.equal(controller.value, [0, 0]); 39 | assert.equal(element.shadowRoot!.textContent, '[0,0]'); 40 | assert.is(storageValue, '[0,0]'); 41 | }); 42 | 43 | test('removes from storage if value undefined', async () => { 44 | controller.value = [0, 0]; 45 | await element.updateComplete; 46 | 47 | controller.value = undefined; 48 | await element.updateComplete; 49 | 50 | const storageValue = window.sessionStorage.getItem('test-key'); 51 | 52 | assert.equal(controller.value, undefined); 53 | assert.equal(element.shadowRoot!.textContent, ''); 54 | assert.is(storageValue, null); 55 | }); 56 | 57 | test('updates other instances', async () => { 58 | const otherController = new SessionStorageController( 59 | element, 60 | 'test-key' 61 | ); 62 | element.controllers.push(otherController); 63 | 64 | otherController.value = [1, 2]; 65 | await element.updateComplete; 66 | 67 | const storageValue = window.sessionStorage.getItem('test-key'); 68 | 69 | assert.equal(otherController.value, [1, 2]); 70 | assert.equal(element.shadowRoot!.textContent, '[1,2]'); 71 | assert.is(storageValue, '[1,2]'); 72 | }); 73 | }); 74 | 75 | test('reacts to storage events', async () => { 76 | window.sessionStorage.setItem('test-key', '[3,4]'); 77 | window.dispatchEvent( 78 | new StorageEvent('storage', { 79 | storageArea: window.sessionStorage, 80 | key: 'test-key' 81 | }) 82 | ); 83 | 84 | await element.updateComplete; 85 | 86 | assert.equal(controller.value, [3, 4]); 87 | assert.equal(element.shadowRoot!.textContent, '[3,4]'); 88 | }); 89 | }); 90 | 91 | suite('with default value', () => { 92 | setup(async () => { 93 | element = document.createElement('test-element') as TestElement; 94 | controller = new SessionStorageController(element, 'test-key', [5, 6]); 95 | element.controllers.push(controller); 96 | element.template = () => html`${JSON.stringify(controller.value)}`; 97 | document.body.appendChild(element); 98 | await element.updateComplete; 99 | }); 100 | 101 | test('initialises to default value', () => { 102 | const storageValue = window.sessionStorage.getItem('test-key'); 103 | 104 | assert.equal(controller.value, [5, 6]); 105 | assert.equal(element.shadowRoot!.textContent, '[5,6]'); 106 | assert.is(storageValue, '[5,6]'); 107 | }); 108 | }); 109 | 110 | suite('with saved value', () => { 111 | setup(() => { 112 | window.sessionStorage.setItem('test-key', JSON.stringify([7, 8])); 113 | element = document.createElement('test-element') as TestElement; 114 | element.template = () => html`${JSON.stringify(controller.value)}`; 115 | document.body.appendChild(element); 116 | }); 117 | 118 | test('initialises to saved value', async () => { 119 | controller = new SessionStorageController(element, 'test-key'); 120 | element.controllers.push(controller); 121 | await element.updateComplete; 122 | 123 | const storageValue = window.sessionStorage.getItem('test-key'); 124 | assert.equal(controller.value, [7, 8]); 125 | assert.equal(element.shadowRoot!.textContent, '[7,8]'); 126 | assert.is(storageValue, '[7,8]'); 127 | }); 128 | 129 | test('ignore default value and use saved value', async () => { 130 | controller = new SessionStorageController(element, 'test-key', [9, 10]); 131 | element.controllers.push(controller); 132 | await element.updateComplete; 133 | 134 | const storageValue = window.sessionStorage.getItem('test-key'); 135 | assert.equal(controller.value, [7, 8]); 136 | assert.equal(element.shadowRoot!.textContent, '[7,8]'); 137 | assert.is(storageValue, '[7,8]'); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/test/controllers/slot_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as hanbi from 'hanbi'; 5 | import * as assert from 'uvu/assert'; 6 | import {SlotController} from '../../main.js'; 7 | import type {TestElement} from '../util.js'; 8 | import {ref, createRef} from 'lit/directives/ref.js'; 9 | 10 | suite('SlotController', () => { 11 | let element: TestElement; 12 | let controller: SlotController; 13 | 14 | setup(() => { 15 | element = document.createElement('test-element'); 16 | }); 17 | 18 | teardown(() => { 19 | element.remove(); 20 | }); 21 | 22 | setup(async () => { 23 | controller = new SlotController(element); 24 | element.controllers.push(controller); 25 | element.template = () => html``; 26 | document.body.appendChild(element); 27 | await element.updateComplete; 28 | }); 29 | 30 | suite('addListener', () => { 31 | test('calls listener with slotted elements', async () => { 32 | const spy = hanbi.spy<(node: Element) => void>(); 33 | 34 | controller.addListener('*', '*', spy.handler); 35 | 36 | element.innerHTML = 'foo
bar
'; 37 | await element.updateComplete; 38 | 39 | const spanNode = element.querySelector('span')!; 40 | const divNode = element.querySelector('div')!; 41 | 42 | assert.is(spy.callCount, 2); 43 | 44 | const firstCall = spy.getCall(0); 45 | const secondCall = spy.getCall(1); 46 | 47 | assert.is(firstCall.args[0], spanNode); 48 | assert.is(secondCall.args[0], divNode); 49 | }); 50 | 51 | test('does not call listener for other slot', async () => { 52 | const spy = hanbi.spy<(node: Element) => void>(); 53 | 54 | element.template = () => html` 55 | 56 | 57 | `; 58 | await element.updateComplete; 59 | 60 | controller.addListener('test', '*', spy.handler); 61 | 62 | element.innerHTML = ` 63 | foo 64 | `; 65 | await element.updateComplete; 66 | 67 | assert.is(spy.callCount, 0); 68 | }); 69 | 70 | test('simple selector matches elements', async () => { 71 | const spy = hanbi.spy<(node: HTMLSpanElement) => void>(); 72 | 73 | controller.addListener('*', 'span', spy.handler); 74 | 75 | element.innerHTML = 'foo
bar
'; 76 | await element.updateComplete; 77 | 78 | const spanNode = element.querySelector('span')!; 79 | 80 | assert.is(spy.callCount, 1); 81 | 82 | const firstCall = spy.getCall(0); 83 | 84 | assert.is(firstCall.args[0], spanNode); 85 | }); 86 | 87 | test('complex selector matches elements', async () => { 88 | const spy = hanbi.spy<(node: Element) => void>(); 89 | 90 | controller.addListener('*', 'span,div', spy.handler); 91 | 92 | element.innerHTML = 'foo
bar

baz

'; 93 | await element.updateComplete; 94 | 95 | const spanNode = element.querySelector('span')!; 96 | const divNode = element.querySelector('div')!; 97 | 98 | assert.is(spy.callCount, 2); 99 | 100 | const firstCall = spy.getCall(0); 101 | const secondCall = spy.getCall(1); 102 | 103 | assert.is(firstCall.args[0], spanNode); 104 | assert.is(secondCall.args[0], divNode); 105 | }); 106 | 107 | test('observes element changes', async () => { 108 | const spy = hanbi.spy<(node: Element) => void>(); 109 | 110 | controller.addListener('*', '*', spy.handler); 111 | 112 | element.template = () => html`
`; 113 | await element.updateComplete; 114 | 115 | element.innerHTML = 'foo
bar
'; 116 | await element.updateComplete; 117 | 118 | assert.is(spy.callCount, 2); 119 | }); 120 | 121 | test('can specify exact slot by name', async () => { 122 | const spy = hanbi.spy<(node: Element) => void>(); 123 | 124 | element.template = () => html``; 125 | await element.updateComplete; 126 | 127 | controller.addListener('test', '*', spy.handler); 128 | 129 | element.innerHTML = 'foo
bar
'; 130 | await element.updateComplete; 131 | 132 | const spanNode = element.querySelector('span')!; 133 | 134 | assert.is(spy.callCount, 1); 135 | 136 | const firstCall = spy.getCall(0); 137 | 138 | assert.is(firstCall.args[0], spanNode); 139 | }); 140 | 141 | test('can specify exact slot by ref', async () => { 142 | const spy = hanbi.spy<(node: Element) => void>(); 143 | const slotRef = createRef(); 144 | 145 | element.template = () => html` 146 | 147 | `; 148 | await element.updateComplete; 149 | 150 | controller.addListener(slotRef, '*', spy.handler); 151 | 152 | element.innerHTML = 'foo
bar
'; 153 | await element.updateComplete; 154 | 155 | const spanNode = element.querySelector('span')!; 156 | 157 | assert.is(spy.callCount, 1); 158 | 159 | const firstCall = spy.getCall(0); 160 | 161 | assert.is(firstCall.args[0], spanNode); 162 | }); 163 | 164 | test('can specify default slot', async () => { 165 | const spy = hanbi.spy<(node: Element) => void>(); 166 | 167 | controller.addListener('[default]', '*', spy.handler); 168 | 169 | element.innerHTML = 'foo
bar
'; 170 | await element.updateComplete; 171 | 172 | const spanNode = element.querySelector('span')!; 173 | const divNode = element.querySelector('div')!; 174 | 175 | assert.is(spy.callCount, 2); 176 | 177 | const firstCall = spy.getCall(0); 178 | const secondCall = spy.getCall(1); 179 | 180 | assert.is(firstCall.args[0], spanNode); 181 | assert.is(secondCall.args[0], divNode); 182 | }); 183 | }); 184 | 185 | suite('has', () => { 186 | test('false if no slot name', async () => { 187 | assert.is(controller.has(''), false); 188 | }); 189 | 190 | test('true if slot has elements', async () => { 191 | element.template = () => html` 192 | 193 | `; 194 | await element.updateComplete; 195 | 196 | assert.is(controller.has('test'), false); 197 | 198 | element.innerHTML = ` 199 | foo 200 | `; 201 | 202 | assert.is(controller.has('test'), true); 203 | }); 204 | 205 | test('can pass slot as ref', async () => { 206 | const slotRef = createRef(); 207 | 208 | element.template = () => html` 209 | 210 | `; 211 | await element.updateComplete; 212 | 213 | assert.is(controller.has(slotRef), false); 214 | 215 | element.innerHTML = ` 216 | foo 217 | `; 218 | 219 | assert.is(controller.has(slotRef), true); 220 | }); 221 | 222 | test('true if any slot has elements (slot=*)', async () => { 223 | element.template = () => html` 224 | 225 | 226 | `; 227 | await element.updateComplete; 228 | 229 | assert.is(controller.has('*'), false); 230 | 231 | element.innerHTML = ` 232 | foo 233 | bar 234 | `; 235 | 236 | assert.is(controller.has('*'), true); 237 | }); 238 | 239 | test('true if any slot has matching elements (slot=*)', async () => { 240 | element.template = () => html` 241 | 242 | 243 | `; 244 | await element.updateComplete; 245 | 246 | assert.is(controller.has('*', 'span'), false); 247 | 248 | element.innerHTML = ` 249 |
foo
250 | bar 251 | `; 252 | 253 | assert.is(controller.has('*', 'span'), true); 254 | }); 255 | 256 | test('true if slot has matching elements', async () => { 257 | element.template = () => html``; 258 | await element.updateComplete; 259 | 260 | element.innerHTML = 'foo'; 261 | 262 | assert.is(controller.has('test', 'span'), true); 263 | assert.is(controller.has('test', 'div'), false); 264 | }); 265 | 266 | test('true if default slot has elements', () => { 267 | assert.is(controller.has('[default]'), false); 268 | 269 | element.innerHTML = 'foo'; 270 | 271 | assert.is(controller.has('[default]'), true); 272 | }); 273 | 274 | test('false if not the specified slot', async () => { 275 | element.template = () => html` 276 | 277 | 278 | `; 279 | await element.updateComplete; 280 | 281 | assert.is(controller.has('test'), false); 282 | 283 | element.innerHTML = ` 284 |
foo
285 | `; 286 | 287 | assert.is(controller.has('test'), false); 288 | }); 289 | 290 | test('triggers re-render when condition changes', async () => { 291 | element.template = () => html` 292 | 293 | ${String(controller.has('*'))} 294 | `; 295 | await element.updateComplete; 296 | 297 | const span = element.shadowRoot!.querySelector('span')!; 298 | 299 | assert.is(span.textContent, 'false'); 300 | 301 | element.innerHTML = 'foo'; 302 | await element.updateComplete; 303 | await element.updateComplete; 304 | 305 | assert.is(span.textContent, 'true'); 306 | }); 307 | 308 | test('handles re-renders with conditional has() calls', async () => { 309 | let flag = true; 310 | 311 | element.template = () => html` 312 | 313 | ${flag ? String(controller.has('*')) : 'nothing'} 314 | `; 315 | await element.updateComplete; 316 | 317 | const span = element.shadowRoot!.querySelector('span')!; 318 | 319 | assert.is(span.textContent, 'false'); 320 | 321 | flag = false; 322 | element.requestUpdate(); 323 | await element.updateComplete; 324 | 325 | assert.is(span.textContent, 'nothing'); 326 | 327 | flag = true; 328 | element.requestUpdate(); 329 | await element.updateComplete; 330 | 331 | assert.is(span.textContent, 'false'); 332 | 333 | element.innerHTML = 'foo'; 334 | await element.updateComplete; 335 | await element.updateComplete; 336 | 337 | assert.is(span.textContent, 'true'); 338 | }); 339 | }); 340 | }); 341 | -------------------------------------------------------------------------------- /src/test/controllers/windowScroll_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import {WindowScrollController} from '../../main.js'; 6 | import type {TestElement} from '../util.js'; 7 | 8 | suite('WindowScrollController', () => { 9 | let element: TestElement; 10 | let controller: WindowScrollController; 11 | let tallDiv: HTMLElement; 12 | 13 | setup(async () => { 14 | tallDiv = document.createElement('div'); 15 | tallDiv.style.height = '110vh'; 16 | tallDiv.style.width = '10rem'; 17 | element = document.createElement('test-element') as TestElement; 18 | controller = new WindowScrollController(element); 19 | element.controllers.push(controller); 20 | element.template = () => html`x: ${controller.x}, y: ${controller.y}`; 21 | document.body.appendChild(element); 22 | document.body.appendChild(tallDiv); 23 | await element.updateComplete; 24 | }); 25 | 26 | teardown(() => { 27 | element.remove(); 28 | tallDiv.remove(); 29 | }); 30 | 31 | test('initialises to current offset', () => { 32 | assert.equal(controller.x, 0); 33 | assert.equal(controller.y, 0); 34 | assert.equal(element.shadowRoot!.textContent, 'x: 0, y: 0'); 35 | }); 36 | 37 | test('observes changes in scroll offset', async () => { 38 | window.scrollBy(0, 10); 39 | 40 | await new Promise((res) => { 41 | window.requestAnimationFrame(res); 42 | }); 43 | await element.updateComplete; 44 | 45 | assert.equal(controller.x, 0); 46 | assert.equal(controller.y, 10); 47 | assert.equal(element.shadowRoot!.textContent, 'x: 0, y: 10'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/test/directives/bindInput_test.ts: -------------------------------------------------------------------------------- 1 | import {html, PropertyDeclarations} from 'lit'; 2 | import * as assert from 'uvu/assert'; 3 | import {bindInput} from '../../main.js'; 4 | import {TestElementBase} from '../util.js'; 5 | 6 | /** 7 | * Test element for the input directive 8 | */ 9 | class BindInputDirectiveTest extends TestElementBase { 10 | public prop?: unknown; 11 | 12 | /** @inheritdoc */ 13 | public static override get properties(): PropertyDeclarations { 14 | return { 15 | prop: {type: Object} 16 | }; 17 | } 18 | } 19 | 20 | customElements.define('bind-input-directive-test', BindInputDirectiveTest); 21 | 22 | suite('bindInput directive', () => { 23 | let element: BindInputDirectiveTest; 24 | 25 | setup(async () => { 26 | element = document.createElement( 27 | 'bind-input-directive-test' 28 | ) as BindInputDirectiveTest; 29 | document.body.appendChild(element); 30 | }); 31 | 32 | teardown(() => { 33 | element.remove(); 34 | }); 35 | 36 | test('throws on non-element binding', async () => { 37 | try { 38 | element.template = () => html` 39 |
${bindInput(element, 'prop')}
40 | `; 41 | await element.updateComplete; 42 | assert.unreachable(); 43 | } catch (err) { 44 | assert.is( 45 | (err as Error).message, 46 | 'The `bindInput` directive must be used in an element or ' + 47 | 'attribute binding' 48 | ); 49 | } 50 | }); 51 | 52 | test('does nothing for non-existent properties', async () => { 53 | element.template = () => html` 54 | 55 | `; 56 | await element.updateComplete; 57 | 58 | const node = element.shadowRoot!.querySelector('input')!; 59 | 60 | assert.is(node.value, ''); 61 | assert.is(element.prop, undefined); 62 | }); 63 | 64 | test('does nothing for non-existent properties (upwards)', async () => { 65 | element.template = () => html` 66 | 67 | `; 68 | await element.updateComplete; 69 | 70 | const node = element.shadowRoot!.querySelector('input')!; 71 | 72 | node.value = 'foo'; 73 | node.dispatchEvent(new Event('input')); 74 | await element.updateComplete; 75 | 76 | assert.is(element.prop, undefined); 77 | assert.is(Object.prototype.hasOwnProperty.call(element, 'nonsense'), false); 78 | }); 79 | 80 | test('survives DOM reconnect', async () => { 81 | element.template = () => html` 82 | 83 | `; 84 | await element.updateComplete; 85 | 86 | const inputNode = element.shadowRoot!.querySelector('input')!; 87 | element.remove(); 88 | 89 | inputNode.value = 'xyz'; 90 | inputNode.dispatchEvent(new Event('input')); 91 | 92 | assert.is(element.prop, undefined); 93 | 94 | document.body.appendChild(element); 95 | 96 | inputNode.value = 'xyz'; 97 | inputNode.dispatchEvent(new Event('input')); 98 | 99 | assert.is(element.prop, 'xyz'); 100 | }); 101 | 102 | suite(' by property', () => { 103 | setup(async () => { 104 | element.template = () => html` 105 | 106 | `; 107 | await element.updateComplete; 108 | }); 109 | 110 | test('propagates value downwards', async () => { 111 | element.prop = 'xyz'; 112 | await element.updateComplete; 113 | 114 | const inputNode = element.shadowRoot!.querySelector('input')!; 115 | 116 | assert.is(inputNode.value, 'xyz'); 117 | }); 118 | 119 | test('propagates value upwards', async () => { 120 | const inputNode = element.shadowRoot!.querySelector('input')!; 121 | 122 | inputNode.value = 'xyz'; 123 | inputNode.dispatchEvent(new Event('input')); 124 | await element.updateComplete; 125 | 126 | assert.is(element.prop, 'xyz'); 127 | }); 128 | }); 129 | 130 | suite(' by attribute', () => { 131 | setup(async () => { 132 | element.template = () => html` 133 | 134 | `; 135 | await element.updateComplete; 136 | }); 137 | 138 | test('propagates value downwards', async () => { 139 | element.prop = 'xyz'; 140 | await element.updateComplete; 141 | 142 | const inputNode = element.shadowRoot!.querySelector('input')!; 143 | 144 | assert.is(inputNode.value, 'xyz'); 145 | }); 146 | 147 | test('propagates value upwards', async () => { 148 | const inputNode = element.shadowRoot!.querySelector('input')!; 149 | 150 | inputNode.value = 'xyz'; 151 | inputNode.dispatchEvent(new Event('input')); 152 | await element.updateComplete; 153 | 154 | assert.is(element.prop, 'xyz'); 155 | }); 156 | }); 157 | 158 | suite('', () => { 159 | setup(async () => { 160 | element.template = () => html` 161 | 162 | `; 163 | await element.updateComplete; 164 | }); 165 | 166 | test('propagates value downwards', async () => { 167 | element.prop = 'xyz'; 168 | await element.updateComplete; 169 | 170 | const inputNode = element.shadowRoot!.querySelector('input')!; 171 | 172 | assert.is(inputNode.value, 'xyz'); 173 | }); 174 | 175 | test('propagates value upwards', async () => { 176 | const inputNode = element.shadowRoot!.querySelector('input')!; 177 | 178 | inputNode.value = 'xyz'; 179 | inputNode.dispatchEvent(new Event('input')); 180 | await element.updateComplete; 181 | 182 | assert.is(element.prop, 'xyz'); 183 | }); 184 | 185 | test('sets empty string on undefined value', async () => { 186 | element.prop = 'xyz'; 187 | await element.updateComplete; 188 | element.prop = undefined; 189 | await element.updateComplete; 190 | 191 | const inputNode = element.shadowRoot!.querySelector('input')!; 192 | 193 | assert.is(inputNode.value, ''); 194 | }); 195 | }); 196 | 197 | suite('', () => { 198 | setup(async () => { 199 | element.template = () => html` 200 | 201 | `; 202 | await element.updateComplete; 203 | }); 204 | 205 | test('propagates value downwards', async () => { 206 | element.prop = true; 207 | await element.updateComplete; 208 | 209 | const node = element.shadowRoot!.querySelector('input')!; 210 | 211 | assert.is(node.checked, true); 212 | }); 213 | 214 | test('propagates value upwards', async () => { 215 | const node = element.shadowRoot!.querySelector('input')!; 216 | 217 | node.checked = true; 218 | node.dispatchEvent(new Event('change')); 219 | await element.updateComplete; 220 | 221 | assert.is(element.prop, true); 222 | }); 223 | }); 224 | 225 | suite(' 229 | 230 | 231 | `; 232 | await element.updateComplete; 233 | }); 234 | 235 | test('propagates value downwards', async () => { 236 | element.prop = 'xyz'; 237 | await element.updateComplete; 238 | 239 | const node = element.shadowRoot!.querySelector('select')!; 240 | 241 | assert.is(node.value, 'xyz'); 242 | }); 243 | 244 | test('propagates value upwards', async () => { 245 | const node = element.shadowRoot!.querySelector('select')!; 246 | 247 | node.value = 'xyz'; 248 | node.dispatchEvent(new Event('change')); 249 | await element.updateComplete; 250 | 251 | assert.is(element.prop, 'xyz'); 252 | }); 253 | }); 254 | 255 | suite(' 259 | 260 | 261 | 262 | `; 263 | await element.updateComplete; 264 | }); 265 | 266 | test('propagates value downwards', async () => { 267 | element.prop = ['808', '303']; 268 | await element.updateComplete; 269 | 270 | const node = element.shadowRoot!.querySelector('select')!; 271 | const opts = [...node.selectedOptions].map((opt) => opt.value); 272 | 273 | assert.is(opts.length, 2); 274 | assert.equal(opts, ['808', '303']); 275 | }); 276 | 277 | test('propagates value upwards', async () => { 278 | const node = element.shadowRoot!.querySelector('select')!; 279 | const opts = [...node.options]; 280 | 281 | opts[0]!.selected = true; 282 | opts[1]!.selected = true; 283 | node.dispatchEvent(new Event('change')); 284 | await element.updateComplete; 285 | 286 | assert.equal(element.prop, ['808', '303']); 287 | }); 288 | }); 289 | 290 | suite(' 294 | `; 295 | await element.updateComplete; 296 | }); 297 | 298 | test('propagates value downwards', async () => { 299 | element.prop = 'xyz'; 300 | await element.updateComplete; 301 | 302 | const node = element.shadowRoot!.querySelector('textarea')!; 303 | 304 | assert.is(node.value, 'xyz'); 305 | }); 306 | 307 | test('propagates value upwards', async () => { 308 | const node = element.shadowRoot!.querySelector('textarea')!; 309 | 310 | node.value = 'xyz'; 311 | node.dispatchEvent(new Event('input')); 312 | await element.updateComplete; 313 | 314 | assert.is(element.prop, 'xyz'); 315 | }); 316 | 317 | test('sets empty string on undefined value', async () => { 318 | element.prop = 'xyz'; 319 | await element.updateComplete; 320 | element.prop = undefined; 321 | await element.updateComplete; 322 | 323 | const node = element.shadowRoot!.querySelector('textarea')!; 324 | 325 | assert.is(node.value, ''); 326 | }); 327 | }); 328 | }); 329 | -------------------------------------------------------------------------------- /src/test/directives/onLongPress_test.ts: -------------------------------------------------------------------------------- 1 | import '../util.js'; 2 | 3 | import {html} from 'lit'; 4 | import * as assert from 'uvu/assert'; 5 | import {onLongPress} from '../../main.js'; 6 | import * as hanbi from 'hanbi'; 7 | import {TestElement, delay} from '../util.js'; 8 | 9 | suite('onLongPress directive', () => { 10 | let element: TestElement; 11 | 12 | setup(async () => { 13 | element = document.createElement('test-element'); 14 | document.body.appendChild(element); 15 | }); 16 | 17 | teardown(() => { 18 | element.remove(); 19 | hanbi.restore(); 20 | }); 21 | 22 | test('throws on non-element binding', async () => { 23 | try { 24 | const callback = (): void => { 25 | return; 26 | }; 27 | element.template = () => html` 28 |
${onLongPress(callback)}
29 | `; 30 | await element.updateComplete; 31 | assert.unreachable(); 32 | } catch (err) { 33 | assert.is( 34 | (err as Error).message, 35 | 'The `onLongPress` directive must be used in an element binding' 36 | ); 37 | } 38 | }); 39 | 40 | test('calls callback after timeout', async () => { 41 | const callback = hanbi.spy(); 42 | element.template = () => html` 43 |
44 | `; 45 | await element.updateComplete; 46 | 47 | const div = element.shadowRoot!.querySelector('div')!; 48 | const ev = new PointerEvent('pointerdown'); 49 | 50 | div.dispatchEvent(ev); 51 | await delay(12); 52 | 53 | assert.equal(callback.called, true); 54 | assert.equal(callback.firstCall!.args, [ev]); 55 | }); 56 | 57 | test('does not call callback if pointer up before timer', async () => { 58 | const callback = hanbi.spy(); 59 | element.template = () => html` 60 |
61 | `; 62 | await element.updateComplete; 63 | 64 | const div = element.shadowRoot!.querySelector('div')!; 65 | const pointerDown = new PointerEvent('pointerdown'); 66 | const pointerUp = new PointerEvent('pointerup'); 67 | 68 | div.dispatchEvent(pointerDown); 69 | await delay(5); 70 | div.dispatchEvent(pointerUp); 71 | await delay(10); 72 | 73 | assert.equal(callback.called, false); 74 | }); 75 | 76 | test('does not call callback if pointer leaves before timer', async () => { 77 | const callback = hanbi.spy(); 78 | element.template = () => html` 79 |
80 | `; 81 | await element.updateComplete; 82 | 83 | const div = element.shadowRoot!.querySelector('div')!; 84 | const pointerDown = new PointerEvent('pointerdown'); 85 | const pointerLeave = new PointerEvent('pointerleave'); 86 | 87 | div.dispatchEvent(pointerDown); 88 | await delay(5); 89 | div.dispatchEvent(pointerLeave); 90 | await delay(10); 91 | 92 | assert.equal(callback.called, false); 93 | }); 94 | 95 | test('does not call callback if disconnected', async () => { 96 | const callback = hanbi.spy(); 97 | element.template = () => html` 98 |
99 | `; 100 | await element.updateComplete; 101 | 102 | const div = element.shadowRoot!.querySelector('div')!; 103 | const ev = new PointerEvent('pointerdown'); 104 | 105 | element.remove(); 106 | 107 | div.dispatchEvent(ev); 108 | await delay(12); 109 | 110 | assert.equal(callback.called, false); 111 | }); 112 | 113 | test('survives reconnection to dom', async () => { 114 | const callback = hanbi.spy(); 115 | element.template = () => html` 116 |
117 | `; 118 | await element.updateComplete; 119 | 120 | const div = element.shadowRoot!.querySelector('div')!; 121 | const ev = new PointerEvent('pointerdown'); 122 | 123 | element.remove(); 124 | document.body.appendChild(element); 125 | await element.updateComplete; 126 | 127 | div.dispatchEvent(ev); 128 | await delay(12); 129 | 130 | assert.equal(callback.called, true); 131 | }); 132 | 133 | test('applies default timeout if none set', async () => { 134 | const callback = hanbi.spy(); 135 | element.template = () => html` 136 |
137 | `; 138 | await element.updateComplete; 139 | 140 | const div = element.shadowRoot!.querySelector('div')!; 141 | const ev = new PointerEvent('pointerdown'); 142 | 143 | div.dispatchEvent(ev); 144 | await delay(500); 145 | 146 | assert.equal(callback.called, false); 147 | 148 | await delay(600); 149 | 150 | assert.equal(callback.called, true); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/test/util.ts: -------------------------------------------------------------------------------- 1 | import {LitElement, html, css} from 'lit'; 2 | import type { 3 | ReactiveController, 4 | TemplateResult, 5 | PropertyDeclarations 6 | } from 'lit'; 7 | 8 | type TestTemplateFn = () => TemplateResult; 9 | 10 | /** 11 | * Base of all test elements 12 | */ 13 | export abstract class TestElementBase extends LitElement { 14 | public static override styles = css` 15 | :host { 16 | display: block; 17 | } 18 | `; 19 | 20 | /** @inheritdoc */ 21 | public static override get properties(): PropertyDeclarations { 22 | return { 23 | template: {type: Object} 24 | }; 25 | } 26 | 27 | public controllers: ReactiveController[] = []; 28 | public template?: TestTemplateFn; 29 | 30 | /** @inheritdoc */ 31 | protected override render(): TemplateResult { 32 | return this.template?.() ?? html``; 33 | } 34 | } 35 | 36 | /** 37 | * Test element with controllers 38 | */ 39 | export class TestElement extends TestElementBase {} 40 | 41 | customElements.define('test-element', TestElement); 42 | 43 | export async function delay(timeoutMs: number): Promise { 44 | await new Promise((r) => setTimeout(r, timeoutMs)); 45 | } 46 | 47 | declare global { 48 | interface HTMLElementTagNameMap { 49 | 'test-element': TestElement; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "es2022", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "outDir": "./lib", 8 | "importHelpers": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "types": ["mocha"], 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictBindCallApply": true, 16 | "strictPropertyInitialization": true, 17 | "noImplicitThis": true, 18 | "useUnknownInCatchVariables": true, 19 | "alwaysStrict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "exactOptionalPropertyTypes": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedIndexedAccess": true, 26 | "noImplicitOverride": true, 27 | "noPropertyAccessFromIndexSignature": true, 28 | "useDefineForClassFields": false 29 | }, 30 | "include": ["src/**/*.ts"] 31 | } 32 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import {puppeteerLauncher} from '@web/test-runner-puppeteer'; 2 | import {esbuildPlugin} from '@web/dev-server-esbuild'; 3 | 4 | export default { 5 | nodeResolve: true, 6 | files: ['src/**/*_test.ts'], 7 | coverage: true, 8 | coverageConfig: { 9 | reporters: ['lcov'] 10 | }, 11 | browsers: [ 12 | puppeteerLauncher({concurrency: 1}) 13 | ], 14 | plugins: [ 15 | esbuildPlugin({ 16 | ts: true, 17 | target: 'auto', 18 | tsconfig: './tsconfig.json' 19 | }) 20 | ], 21 | testFramework: { 22 | config: { 23 | ui: 'tdd' 24 | } 25 | } 26 | }; 27 | --------------------------------------------------------------------------------