Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor target into targetable #257

Merged
merged 4 commits into from
May 26, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
{
"path": "lib/index.js",
"import": "{controller, attr, target, targets}",
"limit": "2.6kb"
"limit": "2.7kb"
},
{
"path": "lib/abilities.js",
Expand Down
37 changes: 0 additions & 37 deletions src/findtarget.ts

This file was deleted.

11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
export {actionable} from './actionable.js'
export {register} from './register.js'
export {findTarget, findTargets} from './findtarget.js'
export {target, targets} from './target.js'
export {
target,
getTarget,
targets,
getTargets,
targetChangedCallback,
targetsChangedCallback,
targetable
} from './targetable.js'
export {controller} from './controller.js'
export {attr, getAttr, attrable, attrChangedCallback} from './attrable.js'
36 changes: 0 additions & 36 deletions src/target.ts

This file was deleted.

120 changes: 120 additions & 0 deletions src/targetable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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<Element>(
({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<Element>(
({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 = controllers.get(controller)!
}
if (controller && get(controller)?.has(key)) {
;(controller as unknown as Record<PropertyKey, unknown>)[key] = {}
}
}

registerTag('data-target', (str: string) => str.split('.'), setTarget)
registerTag('data-targets', (str: string) => str.split('.'), setTarget)
const shadows = new WeakMap<Element, ShadowRoot>()
const controllers = new WeakMap<ShadowRoot, Element>()

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(
<T extends CustomElementClass>(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)
controllers.set(root, this)
observeElementForTags(root)
}
}
)
47 changes: 40 additions & 7 deletions test/target.ts → test/targetable.ts
Original file line number Diff line number Diff line change
@@ -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`<target-test>
<target-test>
Expand All @@ -32,6 +38,10 @@ describe('Targetable', () => {
<div id="el6" data-target="target-test.bar target-test.bing"></div>
<div id="el7" data-target="target-test.bazbaz"></div>
<div id="el8" data-target="other-target.qux target-test.qux"></div>
<div id="el9" data-target="target-test.multi-word"></div>
<div id="el10" data-target="target-test.multiWord"></div>
<div id="el11" data-targets="target-test.camel-case"></div>
<div id="el12" data-targets="target-test.camelCase"></div>
</target-test>`)
})

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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')
})
})
})