diff --git a/package.json b/package.json index cd667259..f57d18aa 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ { "path": "lib/index.js", "import": "{controller, attr, target, targets}", - "limit": "2.5kb" + "limit": "3.0kb" }, { "path": "lib/abilities.js", diff --git a/src/actionable.ts b/src/actionable.ts new file mode 100644 index 00000000..97f66b9b --- /dev/null +++ b/src/actionable.ts @@ -0,0 +1,58 @@ +import type {CustomElementClass, CustomElement} from './custom-element.js' +import type {ControllableClass} from './controllable.js' +import {registerTag, observeElementForTags, parseElementTags} from './tag-observer.js' +import {controllable, attachShadowCallback} from './controllable.js' +import {createAbility} from './ability.js' + +const parseActionAttribute = (tag: string): [tagName: string, event: string, method: string] => { + const eventSep = tag.lastIndexOf(':') + const methodSep = Math.max(0, tag.lastIndexOf('#')) || tag.length + return [tag.slice(eventSep + 1, methodSep), tag.slice(0, eventSep), tag.slice(methodSep + 1) || 'handleEvent'] +} +registerTag( + 'data-action', + parseActionAttribute, + (el: Element, controller: Element | ShadowRoot, tag: string, event: string) => { + el.addEventListener(event, handleEvent) + } +) + +const actionables = new WeakSet() +// Bind a single function to all events to avoid anonymous closure performance penalty. +function handleEvent(event: Event) { + const el = event.currentTarget as Element + for (const [tag, type, method] of parseElementTags(el, 'data-action', parseActionAttribute)) { + if (event.type === type) { + type EventDispatcher = CustomElement & Record unknown> + const controller = el.closest(tag)! + if (actionables.has(controller) && typeof controller[method] === 'function') { + controller[method](event) + } + const root = el.getRootNode() + if (root instanceof ShadowRoot) { + const shadowController = root.host as EventDispatcher + if (shadowController.matches(tag) && actionables.has(shadowController)) { + if (typeof shadowController[method] === 'function') { + shadowController[method](event) + } + } + } + } + } +} + +export const actionable = createAbility( + (Class: T): T & ControllableClass => + class extends controllable(Class) { + constructor() { + super() + actionables.add(this) + observeElementForTags(this) + } + + [attachShadowCallback](root: ShadowRoot) { + super[attachShadowCallback]?.(root) + observeElementForTags(root) + } + } +) diff --git a/src/attr.ts b/src/attr.ts deleted file mode 100644 index 2d7472fc..00000000 --- a/src/attr.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type {CustomElementClass} from './custom-element.js' -import {mustDasherize} from './dasherize.js' -import {meta} from './core.js' - -const attrKey = 'attr' -type attrValue = string | number | boolean - -/** - * Attr is a decorator which tags a property as one to be initialized via - * `initializeAttrs`. - * - * The signature is typed such that the property must be one of a String, - * Number or Boolean. This matches the behavior of `initializeAttrs`. - */ -export function attr(proto: Record, key: K): void { - meta(proto, attrKey).add(key) -} - -/** - * initializeAttrs is called with a set of class property names (if omitted, it - * will look for any properties tagged with the `@attr` decorator). With this - * list it defines property descriptors for each property that map to `data-*` - * attributes on the HTMLElement instance. - * - * It works around Native Class Property semantics - which are equivalent to - * calling `Object.defineProperty` on the instance upon creation, but before - * `constructor()` is called. - * - * If a class property is assigned to the class body, it will infer the type - * (using `typeof`) and define an appropriate getter/setter combo that aligns - * to that type. This means class properties assigned to Numbers can only ever - * be Numbers, assigned to Booleans can only ever be Booleans, and assigned to - * Strings can only ever be Strings. - * - * This is automatically called as part of `@controller`. If a class uses the - * `@controller` decorator it should not call this manually. - */ -const initialized = new WeakSet() -export function initializeAttrs(instance: HTMLElement, names?: Iterable): void { - if (initialized.has(instance)) return - initialized.add(instance) - const proto = Object.getPrototypeOf(instance) - const prefix = proto?.constructor?.attrPrefix ?? 'data-' - if (!names) names = meta(proto, attrKey) - for (const key of names) { - const value = (>(instance))[key] - const name = mustDasherize(`${prefix}${key}`) - let descriptor: PropertyDescriptor = { - configurable: true, - get(this: HTMLElement): string { - return this.getAttribute(name) || '' - }, - set(this: HTMLElement, newValue: string) { - this.setAttribute(name, newValue || '') - } - } - if (typeof value === 'number') { - descriptor = { - configurable: true, - get(this: HTMLElement): number { - return Number(this.getAttribute(name) || 0) - }, - set(this: HTMLElement, newValue: string) { - this.setAttribute(name, newValue) - } - } - } else if (typeof value === 'boolean') { - descriptor = { - configurable: true, - get(this: HTMLElement): boolean { - return this.hasAttribute(name) - }, - set(this: HTMLElement, newValue: boolean) { - this.toggleAttribute(name, newValue) - } - } - } - Object.defineProperty(instance, key, descriptor) - if (key in instance && !instance.hasAttribute(name)) { - descriptor.set!.call(instance, value) - } - } -} - -export function defineObservedAttributes(classObject: CustomElementClass): void { - let observed = classObject.observedAttributes || [] - - const prefix = classObject.attrPrefix ?? 'data-' - const attrToAttributeName = (name: string) => mustDasherize(`${prefix}${name}`) - - Object.defineProperty(classObject, 'observedAttributes', { - configurable: true, - get() { - return [...meta(classObject.prototype, attrKey)].map(attrToAttributeName).concat(observed) - }, - set(attributes: string[]) { - observed = attributes - } - }) -} diff --git a/src/attrable.ts b/src/attrable.ts new file mode 100644 index 00000000..ac833f21 --- /dev/null +++ b/src/attrable.ts @@ -0,0 +1,130 @@ +import type {CustomElementClass} from './custom-element.js' +import type {ControllableClass} from './controllable.js' +import {controllable} from './controllable.js' +import {dasherize, mustDasherize} from './dasherize.js' +import {createMark} from './mark.js' +import {createAbility} from './ability.js' + +const attrChangedCallback = Symbol() +const serializeAttributeName = Symbol() + +export interface Attrable { + [key: PropertyKey]: unknown + [serializeAttributeName](name: PropertyKey): string + [attrChangedCallback](changed: Map): void +} + +export interface AttrableClass { + new (): Attrable +} + +export const deprecatedDataPrefixedAttrs = createAbility( + (Class: T): T => + class extends controllable(Class) { + [serializeAttributeName](name: PropertyKey) { + return `data-${dasherize(name)}` + } + } +) + +const Identity = (v: unknown) => v +let setFromMutation = false +const attrs = new WeakMap>() + +const handleMutations = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const name = mutation.attributeName! + const el = mutation.target as Element & {[key: PropertyKey]: unknown} + const key = attrs.get(el)?.get(name) + if (key) { + setFromMutation = true + el[key] = el.getAttribute(name) + setFromMutation = false + } + } + } +} +const observer = new MutationObserver(handleMutations) + +const [attr, getAttr, initializeAttrs] = createMark( + ({name}) => mustDasherize(name, '@attr'), + (instance: Element & Attrable, {name, kind, access}) => { + let cast: typeof Identity | typeof Boolean | typeof Number | typeof String = Identity + let initialValue: unknown + if (access.get) { + initialValue = access.get.call(instance) + } else if ('value' in access && kind !== 'method') { + initialValue = access.value + } + let value = initialValue + const attributeName = instance[serializeAttributeName](name) + const setCallback = (kind === 'method' ? access.value : access.set) || Identity + const getCallback = access.get || (() => value) + if (!attrs.get(instance)) attrs.set(instance, new Map()) + attrs.get(instance)!.set(attributeName, name) + if (typeof value === 'number') { + cast = Number + } else if (typeof value === 'boolean') { + cast = Boolean + } else if (typeof value === 'string') { + cast = String + } + const queue = new Map() + const requestAttrChanged = async (newValue: unknown) => { + queue.set(name, newValue) + if (queue.size > 1) return + await Promise.resolve() + const changed = new Map(queue) + queue.clear() + instance[attrChangedCallback](changed) + } + return { + get() { + const has = instance.hasAttribute(attributeName) + if (has) { + return cast === Boolean ? has : cast(instance.getAttribute(attributeName)) + } + return cast(getCallback.call(instance)) + }, + set(newValue: unknown) { + const isInitial = newValue === null + if (isInitial) newValue = initialValue + const same = Object.is(value, newValue) + value = newValue + setCallback.call(instance, value) + if (setFromMutation || same || isInitial) return + requestAttrChanged(newValue) + } + } + } +) + +export {attr, getAttr, attrChangedCallback} +export const attrable = createAbility( + (Class: T): T & ControllableClass & AttrableClass => + class extends controllable(Class) { + [key: PropertyKey]: unknown + constructor() { + super() + initializeAttrs(this) + const attributeFilter = Array.from(getAttr(this)).map(name => this[serializeAttributeName](name)) + observer.observe(this, {attributeFilter}) + } + + [serializeAttributeName](name: PropertyKey) { + return dasherize(name) + } + + [attrChangedCallback](changed: Map) { + if (!this.isConnected) return + for (const [name, value] of changed) { + if (typeof value === 'boolean') { + this.toggleAttribute(this[serializeAttributeName](name), value) + } else { + this.setAttribute(this[serializeAttributeName](name), String(value)) + } + } + } + } +) diff --git a/src/auto-shadow-root.ts b/src/auto-shadow-root.ts deleted file mode 100644 index c30fb0e7..00000000 --- a/src/auto-shadow-root.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function autoShadowRoot(element: HTMLElement): void { - for (const template of element.querySelectorAll('template[data-shadowroot]')) { - if (template.parentElement === element) { - element - .attachShadow({ - mode: template.getAttribute('data-shadowroot') === 'closed' ? 'closed' : 'open' - }) - .append(template.content.cloneNode(true)) - } - } -} diff --git a/src/bind.ts b/src/bind.ts deleted file mode 100644 index 6b4dc64b..00000000 --- a/src/bind.ts +++ /dev/null @@ -1,111 +0,0 @@ -const controllers = new WeakSet() - -/* - * Bind `[data-action]` elements from the DOM to their actions. - * - */ -export function bind(controller: HTMLElement): void { - controllers.add(controller) - if (controller.shadowRoot) bindShadow(controller.shadowRoot) - bindElements(controller) - listenForBind(controller.ownerDocument) -} - -export function bindShadow(root: ShadowRoot): void { - bindElements(root) - listenForBind(root) -} - -const observers = new WeakMap() -/** - * Set up observer that will make sure any actions that are dynamically - * injected into `el` will be bound to it's controller. - * - * This returns a Subscription object which you can call `unsubscribe()` on to - * stop further live updates. - */ -export function listenForBind(el: Node = document): Subscription { - if (observers.has(el)) return observers.get(el)! - let closed = false - const observer = new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type === 'attributes' && mutation.target instanceof Element) { - bindActions(mutation.target) - } else if (mutation.type === 'childList' && mutation.addedNodes.length) { - for (const node of mutation.addedNodes) { - if (node instanceof Element) { - bindElements(node) - } - } - } - } - }) - observer.observe(el, {childList: true, subtree: true, attributeFilter: ['data-action']}) - const subscription = { - get closed() { - return closed - }, - unsubscribe() { - closed = true - observers.delete(el) - observer.disconnect() - } - } - observers.set(el, subscription) - return subscription -} - -interface Subscription { - closed: boolean - unsubscribe(): void -} - -function bindElements(root: Element | ShadowRoot) { - for (const el of root.querySelectorAll('[data-action]')) { - bindActions(el) - } - // Also bind the controller to itself - if (root instanceof Element && root.hasAttribute('data-action')) { - bindActions(root) - } -} - -// Bind a single function to all events to avoid anonymous closure performance penalty. -function handleEvent(event: Event) { - const el = event.currentTarget as Element - for (const binding of bindings(el)) { - if (event.type === binding.type) { - type EventDispatcher = HTMLElement & Record unknown> - const controller = el.closest(binding.tag)! - if (controllers.has(controller) && typeof controller[binding.method] === 'function') { - controller[binding.method](event) - } - const root = el.getRootNode() - if (root instanceof ShadowRoot && controllers.has(root.host) && root.host.matches(binding.tag)) { - const shadowController = root.host as EventDispatcher - if (typeof shadowController[binding.method] === 'function') { - shadowController[binding.method](event) - } - } - } - } -} - -type Binding = {type: string; tag: string; method: string} -function* bindings(el: Element): Iterable { - for (const action of (el.getAttribute('data-action') || '').trim().split(/\s+/)) { - const eventSep = action.lastIndexOf(':') - const methodSep = Math.max(0, action.lastIndexOf('#')) || action.length - yield { - type: action.slice(0, eventSep), - tag: action.slice(eventSep + 1, methodSep), - method: action.slice(methodSep + 1) || 'handleEvent' - } || 'handleEvent' - } -} - -function bindActions(el: Element) { - for (const binding of bindings(el)) { - el.addEventListener(binding.type, handleEvent) - } -} diff --git a/src/controller.ts b/src/controller.ts index 13365c8a..69e1c3bb 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1,11 +1,15 @@ -import {CatalystDelegate} from './core.js' import type {CustomElementClass} from './custom-element.js' +import {targetable} from './targetable.js' +import {attrable} from './attrable.js' +import {actionable} from './actionable.js' +import {register} from './register.js' + /** * Controller is a decorator to be used over a class that extends HTMLElement. * It will automatically `register()` the component in the customElement * registry, as well as ensuring `bind(this)` is called on `connectedCallback`, * wrapping the classes `connectedCallback` method if needed. */ -export function controller(classObject: CustomElementClass): void { - new CatalystDelegate(classObject) +export function controller(Class: T) { + return register(actionable(attrable(targetable(Class)))) } diff --git a/src/core.ts b/src/core.ts index 22759e0b..274f88cd 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,7 +1,4 @@ import {register} from './register.js' -import {bind, bindShadow} from './bind.js' -import {autoShadowRoot} from './auto-shadow-root.js' -import {defineObservedAttributes, initializeAttrs} from './attr.js' import type {CustomElementClass} from './custom-element.js' const symbol = Symbol.for('catalyst') @@ -42,7 +39,6 @@ export class CatalystDelegate { } }) - defineObservedAttributes(classObject) register(classObject) } @@ -53,11 +49,7 @@ export class CatalystDelegate { connectedCallback(instance: HTMLElement, connectedCallback: () => void) { instance.toggleAttribute('data-catalyst', true) customElements.upgrade(instance) - autoShadowRoot(instance) - initializeAttrs(instance) - bind(instance) connectedCallback?.call(instance) - if (instance.shadowRoot) bindShadow(instance.shadowRoot) } disconnectedCallback(element: HTMLElement, disconnectedCallback: () => void) { @@ -71,7 +63,6 @@ export class CatalystDelegate { newValue: string | null, attributeChangedCallback: (...args: unknown[]) => void ) { - initializeAttrs(instance) if (name !== 'data-catalyst' && attributeChangedCallback) { attributeChangedCallback.call(instance, name, oldValue, newValue) } diff --git a/src/findtarget.ts b/src/findtarget.ts deleted file mode 100644 index 69f448a5..00000000 --- a/src/findtarget.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * findTarget will run `querySelectorAll` against the given controller, plus - * its shadowRoot, returning any the first child that: - * - * - Matches the selector of `[data-target~="tag.name"]` where tag is the - * tagName of the given HTMLElement, and `name` is the given `name` argument. - * - * - Closest ascendant of the element, that matches the tagname of the - * controller, is the specific instance of the controller itself - in other - * words it is not nested in other controllers of the same type. - * - */ -export function findTarget(controller: HTMLElement, name: string): Element | undefined { - const tag = controller.tagName.toLowerCase() - if (controller.shadowRoot) { - for (const el of controller.shadowRoot.querySelectorAll(`[data-target~="${tag}.${name}"]`)) { - if (!el.closest(tag)) return el - } - } - for (const el of controller.querySelectorAll(`[data-target~="${tag}.${name}"]`)) { - if (el.closest(tag) === controller) return el - } -} - -export function findTargets(controller: HTMLElement, name: string): Element[] { - const tag = controller.tagName.toLowerCase() - const targets = [] - if (controller.shadowRoot) { - for (const el of controller.shadowRoot.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) { - if (!el.closest(tag)) targets.push(el) - } - } - for (const el of controller.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) { - if (el.closest(tag) === controller) targets.push(el) - } - return targets -} diff --git a/src/index.ts b/src/index.ts index 56865e93..3c4288ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,21 @@ -export {bind, listenForBind} from './bind.js' -export {register} from './register.js' -export {findTarget, findTargets} from './findtarget.js' -export {target, targets} from './target.js' export {controller} from './controller.js' -export {attr, initializeAttrs, defineObservedAttributes} from './attr.js' -export {autoShadowRoot} from './auto-shadow-root.js' + +export {register} from './register.js' +export {registerTag, observeElementForTags, parseElementTags} from './tag-observer.js' +export {createMark} from './mark.js' +export {dasherize, mustDasherize} from './dasherize.js' + +export {actionable} from './actionable.js' +export { + target, + getTarget, + targets, + getTargets, + targetChangedCallback, + targetsChangedCallback, + targetable +} from './targetable.js' +export {attr, getAttr, attrable, attrChangedCallback, deprecatedDataPrefixedAttrs} from './attrable.js' export {lazyDefine} from './lazy-define.js' + +export type {CustomElement, CustomElementClass} from './custom-element.js' diff --git a/src/register.ts b/src/register.ts index 97cc1484..5808a90c 100644 --- a/src/register.ts +++ b/src/register.ts @@ -8,8 +8,8 @@ import {dasherize} from './dasherize.js' * * Example: HelloController => hello-controller */ -export function register(classObject: CustomElementClass): CustomElementClass { - const name = dasherize(classObject.name).replace(/-element$/, '') +export function register(classObject: T): T { + const name = dasherize(classObject.name).replace(/-(element|controller|component)$/, '') try { window.customElements.define(name, classObject) diff --git a/src/target.ts b/src/target.ts deleted file mode 100644 index 0ce510e6..00000000 --- a/src/target.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {findTarget, findTargets} from './findtarget.js' -import {meta} from './core.js' - -/** - * Target is a decorator which - when assigned to a property field on the - * class - will override that class field, turning it into a Getter which - * returns a call to `findTarget(this, key)` where `key` is the name of the - * property field. In other words, `@target foo` becomes a getter for - * `findTarget(this, 'foo')`. - */ -export function target(proto: Record, key: K): void { - meta(proto, 'target').add(key) - Object.defineProperty(proto, key, { - configurable: true, - get() { - return findTarget(this, key) - } - }) -} - -/** - * Targets is a decorator which - when assigned to a property field on the - * class - will override that class field, turning it into a Getter which - * returns a call to `findTargets(this, key)` where `key` is the name of the - * property field. In other words, `@targets foo` becomes a getter for - * `findTargets(this, 'foo')`. - */ -export function targets(proto: Record, key: K): void { - meta(proto, 'targets').add(key) - Object.defineProperty(proto, key, { - configurable: true, - get() { - return findTargets(this, key) - } - }) -} diff --git a/src/targetable.ts b/src/targetable.ts new file mode 100644 index 00000000..1f5cd888 --- /dev/null +++ b/src/targetable.ts @@ -0,0 +1,117 @@ +import type {CustomElementClass} from './custom-element.js' +import type {ControllableClass} from './controllable.js' +import {registerTag, observeElementForTags} from './tag-observer.js' +import {createMark} from './mark.js' +import {controllable, attachShadowCallback} from './controllable.js' +import {dasherize} from './dasherize.js' +import {createAbility} from './ability.js' + +export interface Targetable { + [targetChangedCallback](key: PropertyKey, target: Element): void + [targetsChangedCallback](key: PropertyKey, targets: Element[]): void +} +export interface TargetableClass { + new (): Targetable +} + +const targetChangedCallback = Symbol() +const targetsChangedCallback = Symbol() + +const [target, getTarget, initializeTarget] = createMark( + ({name, kind}) => { + if (kind === 'getter') throw new Error(`@target cannot decorate get ${String(name)}`) + }, + (instance: Element, {name, access}) => { + const selector = [ + `[data-target~="${instance.tagName.toLowerCase()}.${dasherize(name)}"]`, + `[data-target~="${instance.tagName.toLowerCase()}.${String(name)}"]` + ] + const find = findTarget(instance, selector.join(', '), false) + return { + get: find, + set: () => { + if (access?.set) access.set.call(instance, find()) + } + } + } +) +const [targets, getTargets, initializeTargets] = createMark( + ({name, kind}) => { + if (kind === 'getter') throw new Error(`@target cannot decorate get ${String(name)}`) + }, + (instance: Element, {name, access}) => { + const selector = [ + `[data-targets~="${instance.tagName.toLowerCase()}.${dasherize(name)}"]`, + `[data-targets~="${instance.tagName.toLowerCase()}.${String(name)}"]` + ] + const find = findTarget(instance, selector.join(', '), true) + return { + get: find, + set: () => { + if (access?.set) access.set.call(instance, find()) + } + } + } +) + +function setTarget(el: Element, controller: Element | ShadowRoot, tag: string, key: string): void { + const get = tag === 'data-targets' ? getTargets : getTarget + if (controller instanceof ShadowRoot) controller = controller.host + + if (controller && get(controller)?.has(key)) { + ;(controller as unknown as Record)[key] = {} + } +} + +registerTag('data-target', (str: string) => str.split('.'), setTarget) +registerTag('data-targets', (str: string) => str.split('.'), setTarget) +const shadows = new WeakMap() + +const findTarget = (controller: Element, selector: string, many: boolean) => () => { + const nodes = [] + const shadow = shadows.get(controller) + if (shadow) { + for (const el of shadow.querySelectorAll(selector)) { + if (!el.closest(controller.tagName)) { + nodes.push(el) + if (!many) break + } + } + } + if (many || !nodes.length) { + for (const el of controller.querySelectorAll(selector)) { + if (el.closest(controller.tagName) === controller) { + nodes.push(el) + if (!many) break + } + } + } + return many ? nodes : nodes[0] +} + +export {target, getTarget, targets, getTargets, targetChangedCallback, targetsChangedCallback} +export const targetable = createAbility( + (Class: T): T & ControllableClass & TargetableClass => + class extends controllable(Class) { + constructor() { + super() + observeElementForTags(this) + initializeTarget(this) + initializeTargets(this) + } + + [targetChangedCallback]() { + return + } + + [targetsChangedCallback]() { + return + } + + [attachShadowCallback](root: ShadowRoot) { + super[attachShadowCallback]?.(root) + shadows.set(this, root) + observeElementForTags(root) + } + } +) diff --git a/test/bind.ts b/test/actionable.ts similarity index 90% rename from test/bind.ts rename to test/actionable.ts index 1047aca0..124619ce 100644 --- a/test/bind.ts +++ b/test/actionable.ts @@ -1,15 +1,15 @@ import {expect, fixture, html} from '@open-wc/testing' import {fake} from 'sinon' -import {controller} from '../src/controller.js' -import {bindShadow} from '../src/bind.js' +import {actionable} from '../src/actionable.js' describe('Actionable', () => { - @controller + @actionable class BindTestElement extends HTMLElement { foo = fake() bar = fake() handleEvent = fake() } + window.customElements.define('bind-test', BindTestElement) let instance: BindTestElement beforeEach(async () => { instance = await fixture(html` @@ -126,7 +126,25 @@ describe('Actionable', () => { el1.setAttribute('data-action', 'click:bind-test#foo') el2.setAttribute('data-action', 'submit:bind-test#foo') const shadowRoot = instance.attachShadow({mode: 'open'}) - bindShadow(shadowRoot) + shadowRoot.append(el1, el2) + + // We need to wait for one microtask after injecting the HTML into to + // controller so that the actions have been bound to the controller. + await Promise.resolve() + + expect(instance.foo).to.have.callCount(0) + el1.click() + expect(instance.foo).to.have.callCount(1) + el2.dispatchEvent(new CustomEvent('submit')) + expect(instance.foo).to.have.callCount(2) + }) + + it('can bind elements within a closed shadowDOM', async () => { + const el1 = document.createElement('div') + const el2 = document.createElement('div') + el1.setAttribute('data-action', 'click:bind-test#foo') + el2.setAttribute('data-action', 'submit:bind-test#foo') + const shadowRoot = instance.attachShadow({mode: 'closed'}) shadowRoot.append(el1, el2) // We need to wait for one microtask after injecting the HTML into to @@ -158,7 +176,6 @@ describe('Actionable', () => { const el1 = document.createElement('div') const el2 = document.createElement('div') const shadowRoot = instance.attachShadow({mode: 'open'}) - bindShadow(shadowRoot) shadowRoot.append(el1, el2) // We need to wait for one microtask after injecting the HTML into to diff --git a/test/attr.ts b/test/attr.ts deleted file mode 100644 index 7eab85bf..00000000 --- a/test/attr.ts +++ /dev/null @@ -1,180 +0,0 @@ -import {expect, fixture, html} from '@open-wc/testing' -import {controller} from '../src/controller.js' -import {attr} from '../src/attr.js' - -describe('Attr', () => { - { - @controller - class InitializeAttrTest extends HTMLElement { - @attr fooBar = 'hello' - fooBaz = 1 - - getCount = 0 - setCount = 0 - #bing = 'world' - get bingBaz() { - this.getCount += 1 - return this.#bing - } - @attr set bingBaz(value: string) { - this.setCount += 1 - this.#bing = value - } - } - - let instance: InitializeAttrTest - beforeEach(async () => { - instance = await fixture(html``) - }) - - it('does not error during creation', () => { - document.createElement('initialize-attr-test') - }) - - it('does not alter field values from their initial value', () => { - expect(instance).to.have.property('fooBar', 'hello') - expect(instance).to.have.property('fooBaz', 1) - expect(instance).to.have.property('bingBaz', 'world') - }) - - it('reflects the initial value as an attribute, if not present', () => { - expect(instance).to.have.attribute('data-foo-bar', 'hello') - expect(instance).to.not.have.attribute('data-foo-baz') - expect(instance).to.have.attribute('data-bing-baz', 'world') - }) - - it('prioritises the value in the attribute over the property', async () => { - instance = await fixture(html``) - expect(instance).to.have.property('fooBar', 'goodbye') - expect(instance).to.have.attribute('data-foo-bar', 'goodbye') - expect(instance).to.have.property('bingBaz', 'universe') - expect(instance).to.have.attribute('data-bing-baz', 'universe') - }) - - it('changes the property when the attribute changes', async () => { - instance.setAttribute('data-foo-bar', 'goodbye') - await Promise.resolve() - expect(instance).to.have.property('fooBar', 'goodbye') - instance.setAttribute('data-bing-baz', 'universe') - await Promise.resolve() - expect(instance).to.have.property('bingBaz', 'universe') - }) - - it('changes the attribute when the property changes', () => { - instance.fooBar = 'goodbye' - expect(instance).to.have.attribute('data-foo-bar', 'goodbye') - instance.bingBaz = 'universe' - expect(instance).to.have.attribute('data-bing-baz', 'universe') - }) - } - - describe('types', () => { - it('infers boolean types from property and uses has/toggleAttribute', async () => { - @controller - class BooleanAttrTest extends HTMLElement { - @attr fooBar = false - } - - const instance = await fixture(html``) - - expect(instance).to.have.property('fooBar', false) - expect(instance).to.not.have.attribute('data-foo-bar') - instance.setAttribute('data-foo-bar', '7') - await Promise.resolve() - expect(instance).to.have.property('fooBar', true) - instance.setAttribute('data-foo-bar', 'hello') - await Promise.resolve() - expect(instance).to.have.property('fooBar', true) - instance.setAttribute('data-foo-bar', 'false') - await Promise.resolve() - expect(instance).to.have.property('fooBar', true) - instance.removeAttribute('data-foo-bar') - await Promise.resolve() - expect(instance).to.have.property('fooBar', false) - instance.fooBar = true - await Promise.resolve() - expect(instance).to.have.attribute('data-foo-bar', '') - instance.fooBar = false - await Promise.resolve() - expect(instance).to.not.have.attribute('data-foo-bar') - instance.removeAttribute('data-foo-bar') - await Promise.resolve() - expect(instance).to.have.property('fooBar', false) - }) - - it('avoids infinite loops', async () => { - @controller - class LoopAttrTest extends HTMLElement { - count = 0 - @attr - get fooBar() { - return ++this.count - } - set fooBar(value) { - this.count += 1 - } - } - - const instance = await fixture(html``) - - expect(instance).to.have.property('fooBar') - instance.fooBar = 1 - instance.setAttribute('data-foo-bar', '2') - instance.fooBar = 3 - instance.setAttribute('data-foo-bar', '4') - }) - }) - - describe('naming', () => { - @controller - class NamingAttrTest extends HTMLElement { - @attr fooBarBazBing = 'a' - @attr URLBar = 'b' - @attr ClipX = 'c' - } - - let instance: NamingAttrTest - beforeEach(async () => { - instance = await fixture(html``) - }) - - it('converts camel cased property names to their HTML dasherized equivalents', async () => { - expect(instance.fooBarBazBing).to.equal('a') - instance.fooBarBazBing = 'bar' - expect(instance.getAttributeNames()).to.include('data-foo-bar-baz-bing') - }) - - it('will intuitively dasherize acryonyms', async () => { - expect(instance.URLBar).to.equal('b') - instance.URLBar = 'bar' - expect(instance.getAttributeNames()).to.include('data-url-bar') - }) - - it('dasherizes cap suffixed names correctly', async () => { - expect(instance.ClipX).to.equal('c') - instance.ClipX = 'bar' - expect(instance.getAttributeNames()).to.include('data-clip-x') - }) - }) - - describe('prefix', () => { - @controller - class PrefixAttrTest extends HTMLElement { - static attrPrefix = 'foo-' - @attr fooBarBazBing = 'a' - @attr URLBar = 'b' - @attr ClipX = 'c' - } - - let instance: PrefixAttrTest - beforeEach(async () => { - instance = await fixture(html``) - }) - - it('respects custom attrPrefix static member', async () => { - expect(instance.getAttributeNames()).to.include('foo-foo-bar-baz-bing') - expect(instance.getAttributeNames()).to.include('foo-url-bar') - expect(instance.getAttributeNames()).to.include('foo-clip-x') - }) - }) -}) diff --git a/test/attrable.ts b/test/attrable.ts new file mode 100644 index 00000000..7a9521a3 --- /dev/null +++ b/test/attrable.ts @@ -0,0 +1,346 @@ +import {expect, fixture, html} from '@open-wc/testing' +import {attr, attrable, deprecatedDataPrefixedAttrs} from '../src/attrable.js' + +describe('Attrable', () => { + { + @attrable + class InitializeAttrTest extends HTMLElement { + @attr fooBar = 'hello' + fooBaz = 1 + + getCount = 0 + setCount = 0 + #bing = 'world' + get bingBaz() { + this.getCount += 1 + return this.#bing + } + @attr set bingBaz(value: string) { + this.setCount += 1 + this.#bing = value + } + lastSetHasFoo: any + @attr get hasFoo() { + return false + } + set hasFoo(v: boolean) { + this.lastSetHasFoo = v + } + connectedCallback() { + this.hasFoo = true + } + } + window.customElements.define('initialize-attr-test', InitializeAttrTest) + + let instance: InitializeAttrTest + beforeEach(async () => { + instance = await fixture(html``) + }) + + it('does not error during creation', () => { + document.createElement('initialize-attr-test') + }) + + it('does not alter field values from their initial value', () => { + expect(instance).to.have.property('fooBar', 'hello') + expect(instance).to.have.property('fooBaz', 1) + expect(instance).to.have.property('bingBaz', 'world') + }) + + it('does not create attributes based on the initial value', () => { + expect(instance).to.not.have.attribute('foo-bar') + expect(instance).to.not.have.attribute('foo-baz') + expect(instance).to.not.have.attribute('bing-baz') + }) + + it('prioritises the value in the attribute over the property', async () => { + instance = await fixture(html``) + expect(instance).to.have.property('fooBar', 'goodbye') + expect(instance).to.have.attribute('foo-bar', 'goodbye') + expect(instance).to.have.property('bingBaz', 'universe') + expect(instance).to.have.attribute('bing-baz', 'universe') + }) + + it('changes the property when the attribute changes', async () => { + instance.setAttribute('foo-bar', 'goodbye') + await Promise.resolve() + expect(instance).to.have.property('fooBar', 'goodbye') + instance.setAttribute('bing-baz', 'universe') + await Promise.resolve() + expect(instance).to.have.property('bingBaz', 'universe') + }) + + it('resets to the default value when the attribute is removed', async () => { + instance.setAttribute('foo-bar', 'goodbye') + expect(instance).to.have.property('fooBar', 'goodbye') + instance.setAttribute('foo-bar', 'goodbye') + instance.removeAttribute('foo-bar') + await Promise.resolve() + expect(instance).to.have.property('fooBar', 'hello') + }) + + it('changes the attribute when the property changes', async () => { + instance.fooBar = 'goodbye' + await Promise.resolve() + expect(instance).to.have.attribute('foo-bar', 'goodbye') + instance.bingBaz = 'universe' + await Promise.resolve() + expect(instance).to.have.attribute('bing-baz', 'universe') + }) + + it('calls underlying get when retrieving, with no attribute set', async () => { + instance.getCount = 0 + instance.setCount = 0 + instance.removeAttribute('bing-baz') + instance.bingBaz + expect(instance).to.have.property('getCount', 1) + }) + + it('does not overly eagerly call get/set on attribute change', async () => { + instance.getCount = 0 + instance.setCount = 0 + instance.setAttribute('bing-baz', 'one') + instance.setAttribute('bing-baz', 'one') + instance.setAttribute('bing-baz', 'one') + instance.setAttribute('bing-baz', 'one') + await Promise.resolve() + expect(instance).to.have.property('getCount', 0) + expect(instance).to.have.property('setCount', 4) + }) + + it('updates properties synchronously ', () => { + instance.fooBar = 'goodbye' + expect(instance).to.have.property('fooBar', 'goodbye') + instance.bingBaz = 'universe' + expect(instance).to.have.property('bingBaz', 'universe') + }) + + it('updates default-valued properties in the connected callback', async () => { + expect(instance).to.have.property('lastSetHasFoo', true) + }) + } + + describe('types', () => { + it('infers number types from property and casts as number always', async () => { + @attrable + class NumberAttrTest extends HTMLElement { + @attr fooBar = 1 + } + window.customElements.define('number-attr-test', NumberAttrTest) + const instance = await fixture(html``) + + expect(instance).to.have.property('fooBar', 1) + expect(instance).to.not.have.attribute('foo-bar') + instance.setAttribute('foo-bar', '7') + await Promise.resolve() + expect(instance).to.have.property('fooBar', 7) + instance.setAttribute('foo-bar', '-3.14') + await Promise.resolve() + expect(instance).to.have.property('fooBar', -3.14) + instance.setAttribute('foo-bar', 'Not a Number') + await Promise.resolve() + expect(instance).to.have.property('fooBar').satisfy(Number.isNaN) + instance.fooBar = 3.14 + await Promise.resolve() + expect(instance.getAttribute('foo-bar')).to.equal('3.14') + instance.removeAttribute('foo-bar') + await Promise.resolve() + expect(instance).to.have.property('fooBar', 1) + }) + + it('infers boolean types from property and uses has/toggleAttribute', async () => { + @attrable + class BooleanAttrTest extends HTMLElement { + @attr fooBar = false + } + window.customElements.define('boolean-attr-test', BooleanAttrTest) + + const instance = await fixture(html``) + + expect(instance).to.have.property('fooBar', false) + expect(instance).to.not.have.attribute('foo-bar') + instance.setAttribute('foo-bar', '7') + await Promise.resolve() + expect(instance).to.have.property('fooBar', true) + instance.setAttribute('foo-bar', 'hello') + await Promise.resolve() + expect(instance).to.have.property('fooBar', true) + instance.setAttribute('foo-bar', 'false') + await Promise.resolve() + expect(instance).to.have.property('fooBar', true) + instance.removeAttribute('foo-bar') + await Promise.resolve() + expect(instance).to.have.property('fooBar', false) + instance.fooBar = true + await Promise.resolve() + expect(instance).to.have.attribute('foo-bar', '') + instance.fooBar = false + await Promise.resolve() + expect(instance).to.have.property('fooBar', false) + expect(instance).to.not.have.attribute('foo-bar') + instance.removeAttribute('foo-bar') + await Promise.resolve() + expect(instance).to.have.property('fooBar', false) + expect(instance).to.not.have.attribute('foo-bar') + }) + + it('defaults to inferring string type for non-boolean non-number types', async () => { + const regexp = /^a regexp$/ + @attrable + class RegExpAttrTest extends HTMLElement { + @attr fooBar = regexp + } + window.customElements.define('reg-exp-attr-test', RegExpAttrTest) + const instance = await fixture(html``) + + expect(instance).to.have.property('fooBar', regexp) + expect(instance).to.not.have.attribute('foo-bar') + instance.setAttribute('foo-bar', '/^another$/') + await Promise.resolve() + expect(instance).to.have.property('fooBar', '/^another$/') + instance.removeAttribute('foo-bar') + await Promise.resolve() + expect(instance).to.have.property('fooBar', regexp) + expect(instance).to.not.have.attribute('foo-bar') + }) + + it('uses get logic to retrieve value without attribute set', async () => { + let n = 0.5 + @attrable + class SeedValueAttrTest extends HTMLElement { + @attr + get seedValue() { + return n + } + set seedValue(newValue: number) {} + } + window.customElements.define('seed-value-attr-test', SeedValueAttrTest) + const instance = await fixture(html``) + + expect(instance).to.have.property('seedValue', 0.5) + n = 1 + expect(instance).to.have.property('seedValue', 1) + expect(instance).to.not.have.attribute('seed-value') + instance.setAttribute('seed-value', '3') + expect(instance).to.have.property('seedValue', 3) + instance.seedValue = 8 + await Promise.resolve() + expect(instance).to.have.attribute('seed-value', '8') + expect(instance).to.have.property('seedValue', 8) + n = 17 + instance.removeAttribute('seed-value') + expect(instance).to.have.property('seedValue', 17) + }) + + it('can derive from internal state', async () => { + @attrable + class InternalStateAttrTest extends HTMLElement { + state = 'b' + @attr + get isA(): boolean { + return this.state === 'a' + } + set isA(value: boolean) { + this.state = value ? 'a' : 'b' + } + } + window.customElements.define('internal-state-attr-test', InternalStateAttrTest) + const instance = await fixture(html``) + + expect(instance).to.have.property('state', 'b') + expect(instance).to.have.property('isA', false) + expect(instance).to.not.have.attribute('is-a', '') + instance.isA = true + expect(instance).to.have.property('state', 'a') + await Promise.resolve() + expect(instance).to.have.property('state', 'a') + expect(instance).to.have.property('isA', true) + expect(instance).to.have.attribute('is-a') + }) + + it('avoids infinite loops', async () => { + @attrable + class LoopAttrTest extends HTMLElement { + count = 0 + @attr + get fooBar() { + return ++this.count + } + set fooBar(value) { + this.count += 1 + } + } + window.customElements.define('loop-attr-test', LoopAttrTest) + const instance = await fixture(html``) + + expect(instance).to.have.property('fooBar') + instance.fooBar = 1 + instance.setAttribute('foo-bar', '2') + instance.fooBar = 3 + instance.setAttribute('foo-bar', '4') + }) + }) + + describe('naming', () => { + @attrable + class NamingAttrTest extends HTMLElement { + @attr fooBarBazBing = 'a' + @attr URLBar = 'b' + @attr ClipX = 'c' + } + window.customElements.define('naming-attr-test', NamingAttrTest) + + let instance: NamingAttrTest + beforeEach(async () => { + instance = await fixture(html``) + }) + + it('converts camel cased property names to their HTML dasherized equivalents', async () => { + expect(instance.fooBarBazBing).to.equal('a') + instance.fooBarBazBing = 'bar' + await Promise.resolve() + expect(instance.getAttributeNames()).to.include('foo-bar-baz-bing') + }) + + it('will intuitively dasherize acryonyms', async () => { + expect(instance.URLBar).to.equal('b') + instance.URLBar = 'bar' + await Promise.resolve() + expect(instance.getAttributeNames()).to.include('url-bar') + }) + + it('dasherizes cap suffixed names correctly', async () => { + expect(instance.ClipX).to.equal('c') + instance.ClipX = 'bar' + await Promise.resolve() + expect(instance.getAttributeNames()).to.include('clip-x') + }) + }) + + describe('deprecated naming', () => { + @deprecatedDataPrefixedAttrs + @attrable + class DeprecatedNamingAttrTest extends HTMLElement { + @attr fooBarBazBing = 'a' + @attr URLBar = 'b' + @attr ClipX = 'c' + } + window.customElements.define('deprecated-naming-attr-test', DeprecatedNamingAttrTest) + + let instance: DeprecatedNamingAttrTest + beforeEach(async () => { + instance = await fixture(html``) + }) + + it('prefixes all attrs with data-', async () => { + expect(instance.fooBarBazBing).to.equal('a') + instance.fooBarBazBing = 'bar' + instance.URLBar = 'bar' + instance.ClipX = 'bar' + await Promise.resolve() + expect(instance.getAttributeNames()).to.include('data-foo-bar-baz-bing') + expect(instance.getAttributeNames()).to.include('data-url-bar') + expect(instance.getAttributeNames()).to.include('data-clip-x') + }) + }) +}) diff --git a/test/auto-shadow-root.ts b/test/auto-shadow-root.ts deleted file mode 100644 index 221c3da9..00000000 --- a/test/auto-shadow-root.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {expect, fixture, html} from '@open-wc/testing' -import {replace, fake} from 'sinon' -import {autoShadowRoot} from '../src/auto-shadow-root.js' - -describe('autoShadowRoot', () => { - class ShadowRootTestElement extends HTMLElement { - declare shadowRoot: ShadowRoot - } - window.customElements.define('shadowroot-test-element', ShadowRootTestElement) - - let instance: ShadowRootTestElement - beforeEach(async () => { - instance = await fixture(html``) - }) - - it('automatically declares shadowroot for elements with `template[data-shadowroot]` children', async () => { - instance = await fixture(html` - - `) - autoShadowRoot(instance) - - expect(instance).to.have.property('shadowRoot').not.equal(null) - expect(instance.shadowRoot.textContent).to.equal('Hello World') - }) - - it('does not attach shadowroot without a template`data-shadowroot` child', async () => { - instance = await fixture(html` - -
World
-
`) - - autoShadowRoot(instance) - - expect(instance).to.have.property('shadowRoot').equal(null) - }) - - it('does not attach shadowroots which are not direct children of the element', async () => { - instance = await fixture(html` -
- -
-
`) - - autoShadowRoot(instance) - - expect(instance).to.have.property('shadowRoot').equal(null) - }) - - it('attaches shadowRoot nodes open by default', async () => { - instance = await fixture(html` - - `) - - autoShadowRoot(instance) - - expect(instance).to.have.property('shadowRoot').not.equal(null) - expect(instance.shadowRoot.textContent).to.equal('Hello World') - }) - - it('attaches shadowRoot nodes closed if `data-shadowroot` is `closed`', async () => { - instance = await fixture(html` - - `) - let shadowRoot: ShadowRoot | null = null - replace( - instance, - 'attachShadow', - fake((...args) => { - shadowRoot = Element.prototype.attachShadow.apply(instance, args) - return shadowRoot - }) - ) - - autoShadowRoot(instance) - - expect(instance).to.have.property('shadowRoot').equal(null) - expect(instance.attachShadow).to.have.been.calledOnceWith({mode: 'closed'}) - expect(shadowRoot!.textContent).to.equal('Hello World') - }) -}) diff --git a/test/controller.ts b/test/controller.ts index cb6abe57..9416f66a 100644 --- a/test/controller.ts +++ b/test/controller.ts @@ -1,7 +1,6 @@ import {expect, fixture, html} from '@open-wc/testing' import {replace, fake} from 'sinon' import {controller} from '../src/controller.js' -import {attr} from '../src/attr.js' describe('controller', () => { let instance @@ -65,30 +64,6 @@ describe('controller', () => { expect(instance.foo).to.have.callCount(1) }) - it('binds auto shadowRoots', async () => { - @controller - class ControllerBindAutoShadowElement extends HTMLElement { - foo() { - return 'foo' - } - } - instance = await fixture(html` - - - - `) - replace(instance, 'foo', fake(instance.foo)) - - expect(instance.shadowRoot).to.exist - expect(instance).to.have.property('shadowRoot').not.equal(null) - expect(instance.shadowRoot!.children).to.have.lengthOf(1) - instance.shadowRoot!.querySelector('button')!.click() - - expect(instance.foo).to.have.callCount(1) - }) - it('upgrades child decendants when connected', async () => { @controller // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -107,35 +82,4 @@ describe('controller', () => { `) }) - - describe('attrs', () => { - let attrValues: string[] = [] - @controller - class AttributeTestElement extends HTMLElement { - foo = 'baz' - attributeChangedCallback() { - attrValues.push(this.getAttribute('data-foo')!) - attrValues.push(this.foo) - } - } - attr(AttributeTestElement.prototype, 'foo') - - beforeEach(() => { - attrValues = [] - }) - - it('initializes attrs as attributes in attributeChangedCallback', async () => { - instance = await fixture(html``) - instance.foo = 'bar' - instance.attributeChangedCallback() - expect(attrValues).to.eql(['bar', 'bar']) - }) - - it('initializes attributes as attrs in attributeChangedCallback', async () => { - instance = await fixture(html``) - instance.setAttribute('data-foo', 'bar') - instance.attributeChangedCallback() - expect(attrValues).to.eql(['bar', 'bar']) - }) - }) }) diff --git a/test/providable.ts b/test/providable.ts index 41db8456..b130f196 100644 --- a/test/providable.ts +++ b/test/providable.ts @@ -1,9 +1,13 @@ import {expect, fixture, html} from '@open-wc/testing' import {fake} from 'sinon' import {provide, provideAsync, consume, providable, ContextEvent} from '../src/providable.js' +import {target, targetable} from '../src/targetable.js' +import {attr, attrable} from '../src/attrable.js' describe('Providable', () => { const sym = Symbol('bing') + @attrable + @targetable @providable class ProvidableProviderTest extends HTMLElement { @provide foo = 'hello' @@ -13,6 +17,8 @@ describe('Providable', () => { } @provide [sym] = {provided: true} @provide qux = 8 + @provide @attr testAttribute = '' + @provide @target target!: HTMLElement } window.customElements.define('providable-provider-test', ProvidableProviderTest) @@ -52,6 +58,8 @@ describe('Providable', () => { @consume set qux(value: number) { this.count += 1 } + @consume target!: HTMLElement + @consume testAttribute = '' connectedCallback() { this.textContent = `${this.foo} ${this.bar}` } @@ -86,7 +94,7 @@ describe('Providable', () => { }) it('emits the `context-request` event when connected, for each field', async () => { - expect(events).to.have.callCount(5) + expect(events).to.have.callCount(7) const fooEvent = events.getCall(0).args[0] expect(fooEvent).to.be.instanceof(ContextEvent) expect(fooEvent).to.have.nested.property('context.name', 'foo') @@ -118,13 +126,27 @@ describe('Providable', () => { const quxEvent = events.getCall(4).args[0] expect(quxEvent).to.be.instanceof(ContextEvent) expect(quxEvent).to.have.nested.property('context.name', 'qux') - expect(quxEvent).to.have.nested.property('context.initialValue').eql(0) + expect(quxEvent).to.have.nested.property('context.initialValue', 0) expect(quxEvent).to.have.property('multiple', true) expect(quxEvent).to.have.property('bubbles', true) + + const targetEvent = events.getCall(5).args[0] + expect(targetEvent).to.be.instanceof(ContextEvent) + expect(targetEvent).to.have.nested.property('context.name', 'target') + expect(targetEvent).to.have.nested.property('context.initialValue', undefined) + expect(targetEvent).to.have.property('multiple', true) + expect(targetEvent).to.have.property('bubbles', true) + + const attrEvent = events.getCall(6).args[0] + expect(attrEvent).to.be.instanceof(ContextEvent) + expect(attrEvent).to.have.nested.property('context.name', 'testAttribute') + expect(attrEvent).to.have.nested.property('context.initialValue', '') + expect(attrEvent).to.have.property('multiple', true) + expect(attrEvent).to.have.property('bubbles', true) }) it('changes value based on callback new value', async () => { - expect(events).to.have.callCount(5) + expect(events).to.have.callCount(7) const fooCallback = events.getCall(0).args[0].callback fooCallback('hello') expect(instance).to.have.property('foo', 'hello') @@ -135,7 +157,7 @@ describe('Providable', () => { it('disposes of past callbacks when given new ones', async () => { const dispose1 = fake() const dispose2 = fake() - expect(events).to.have.callCount(5) + expect(events).to.have.callCount(7) const fooCallback = events.getCall(0).args[0].callback fooCallback('hello', dispose1) expect(dispose1).to.have.callCount(0) @@ -156,10 +178,11 @@ describe('Providable', () => { let provider: ProvidableProviderTest beforeEach(async () => { provider = await fixture( - html`
-
` + html` +
+ +
+
` ) }) @@ -205,7 +228,7 @@ describe('Providable', () => { let provider: ProvidableProviderTest let consumer: ProvidableConsumerTest beforeEach(async () => { - provider = await fixture(html` + provider = await fixture(html`
@@ -215,6 +238,7 @@ describe('Providable', () => {
+
`) consumer = provider.querySelector('providable-consumer-test')! }) @@ -224,7 +248,9 @@ describe('Providable', () => { expect(consumer).to.have.property('bar', 'world') expect(consumer).to.have.property('baz', 3) expect(consumer).to.have.property(sym).eql({provided: true}) - expect(consumer).to.have.property('qux').eql(8) + expect(consumer).to.have.property('qux', 8) + expect(consumer).to.have.property('target', provider.querySelector('small')!) + expect(consumer).to.have.property('testAttribute', 'x') }) it('updates values provided if they change', () => { @@ -234,6 +260,20 @@ describe('Providable', () => { expect(consumer).to.have.property('foo', 'greetings') }) + it('updates @provide @attr values if they change', async () => { + provider.setAttribute('test-attribute', 'y') + await Promise.resolve() + expect(consumer).to.have.property('testAttribute', 'y') + }) + + it('updates @provide @target values if they change', async () => { + const big = document.createElement('big') + big.setAttribute('data-target', 'providable-provider-test.target') + provider.prepend(big) + await Promise.resolve() + expect(consumer).to.have.property('target', big) + }) + it('calls consumer set callbacks when the value is updated', () => { expect(consumer).to.have.property('qux', 8) expect(consumer).to.have.property('count', 1) diff --git a/test/register.ts b/test/register.ts index 7b923f96..68de49a1 100644 --- a/test/register.ts +++ b/test/register.ts @@ -70,4 +70,16 @@ describe('register', () => { class FirstSuffixElement extends HTMLElement {} expect(window.customElements.get('first-suffix')).to.equal(FirstSuffixElement) }) + + it('automatically drops the `Controller` suffix', () => { + @register + class SecondSuffixController extends HTMLElement {} + expect(window.customElements.get('second-suffix')).to.equal(SecondSuffixController) + }) + + it('automatically drops the `Component` suffix', () => { + @register + class ThirdSuffixComponent extends HTMLElement {} + expect(window.customElements.get('third-suffix')).to.equal(ThirdSuffixComponent) + }) }) diff --git a/test/target.ts b/test/targetable.ts similarity index 67% rename from test/target.ts rename to test/targetable.ts index 8967040c..d43d4fca 100644 --- a/test/target.ts +++ b/test/targetable.ts @@ -1,25 +1,31 @@ import {expect, fixture, html} from '@open-wc/testing' -import {target, targets} from '../src/target.js' -import {controller} from '../src/controller.js' +import {target, targets, targetable} from '../src/targetable.js' describe('Targetable', () => { - @controller - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class TargetTestElement extends HTMLElement { + @targetable + class TargetTest extends HTMLElement { @target foo!: Element bar = 'hello' - @target baz!: Element + count = 0 + _baz!: Element + @target set baz(value: Element) { + this.count += 1 + this._baz = value + } @target qux!: Element @target shadow!: Element @target bing!: Element + @target multiWord!: Element @targets foos!: Element[] bars = 'hello' @target quxs!: Element[] @target shadows!: Element[] + @targets camelCase!: Element[] } + window.customElements.define('target-test', TargetTest) - let instance: HTMLElement + let instance: TargetTest beforeEach(async () => { instance = await fixture(html` @@ -32,6 +38,10 @@ describe('Targetable', () => {
+
+
+
+
`) }) @@ -72,6 +82,23 @@ describe('Targetable', () => { instance.shadowRoot!.appendChild(shadowEl) expect(instance).to.have.property('foo', shadowEl) }) + + it('dasherises target name but falls back to authored case', async () => { + expect(instance).to.have.property('multiWord').exist.with.attribute('id', 'el9') + instance.querySelector('#el9')!.remove() + expect(instance).to.have.property('multiWord').exist.with.attribute('id', 'el10') + }) + + it('calls setter when new target has been found', async () => { + expect(instance).to.have.property('baz').exist.with.attribute('id', 'el5') + expect(instance).to.have.property('_baz').exist.with.attribute('id', 'el5') + instance.count = 0 + instance.querySelector('#el4')!.setAttribute('data-target', 'target-test.baz') + await Promise.resolve() + expect(instance).to.have.property('baz').exist.with.attribute('id', 'el4') + expect(instance).to.have.property('_baz').exist.with.attribute('id', 'el4') + expect(instance).to.have.property('count', 1) + }) }) describe('targets', () => { @@ -94,5 +121,11 @@ describe('Targetable', () => { expect(instance).to.have.nested.property('foos[3]').with.attribute('id', 'el4') expect(instance).to.have.nested.property('foos[4]').with.attribute('id', 'el5') }) + + it('returns camel case and dasherised element names', async () => { + expect(instance).to.have.property('camelCase').with.lengthOf(2) + expect(instance).to.have.nested.property('camelCase[0]').with.attribute('id', 'el11') + expect(instance).to.have.nested.property('camelCase[1]').with.attribute('id', 'el12') + }) }) })