Skip to content

Commit

Permalink
Add style and slot transforms (#3)
Browse files Browse the repository at this point in the history
* CSS transformer
* Force tests to pass
* Don't run tests for now
* Update package lock
* Make tests pass
* light slots
* Light refactor
* slotting fix
* this again
* Add failing tests
* Make some tests pass
* All tests pass on Chrome
* All tests passing
* getName on Safari
* unnamed slot does not throw error
* added test for as
* Switch is faster than if/else
* remove private class methods for unpkg
* Rename PR
* Install playwright

---------

Signed-off-by: macdonst <[email protected]>
Co-authored-by: Ryan Bethel <[email protected]>
  • Loading branch information
macdonst and ryanbethel authored Oct 30, 2023
1 parent ce912af commit be90d19
Show file tree
Hide file tree
Showing 8 changed files with 857 additions and 4,103 deletions.
8 changes: 2 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ jobs:
- name: Install
run: npm ci

- name: Run Tests
- name: Test
run: npm test
env:
CI: true

# ----- Only git tag testing + package publishing beyond this point ----- #

Expand All @@ -53,10 +51,8 @@ jobs:
- name: Install
run: npm ci

- name: Run Tests
- name: Test
run: npm test
env:
CI: true

# Publish to npm
- name: Publish @RC to npm
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# custom-element-mixin
Custom Element mixin that enables the reuse of single file components

## Install
## Install

`npm i @enhance/custom-element-mixin`

Expand Down
156 changes: 150 additions & 6 deletions index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,164 @@
const CustomElementMixin = (superclass) => class extends superclass {
constructor() {
super()
// Removes style tags as they are already inserted into the head by SSR
// TODO: If only added dynamically in the browser we need to insert the style tag after running the style transform on it. As well as handle deduplication.
this.template.content.querySelectorAll('style')
.forEach((tag) => { this.template.content.removeChild(tag) })

// Has this element been server side rendered
const enhanced = this.hasAttribute('enhanced')

// Handle style tags
if (enhanced) {
// Removes style tags as they are already inserted into the head by SSR
this.template.content.querySelectorAll('style')
.forEach((tag) => { this.template.content.removeChild(tag) })
} else {
let tagName = customElements.getName ? customElements.getName(this.constructor) : this.toKebabCase(this.constructor.name)
this.template.content.querySelectorAll('style')
.forEach((tag) => {
let sheet = this.styleTransform({ tag, tagName, scope: tag.getAttribute('scope') })
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]
this.template.content.removeChild(tag)
})
}

// Removes script tags as they are already appended to the body by SSR
// TODO: If only added dynamically in the browser we need to insert the script tag after running the script transform on it. As well as handle deduplication.
this.template.content.querySelectorAll('script')
.forEach((tag) => { this.template.content.removeChild(tag) })

// Expands the Custom Element with the template content
const hasSlots = this.template.content.querySelectorAll('slot')?.length

// If the Custom Element was already expanded by SSR it will have the "enhanced" attribute so do not replaceChildren
if (!this.hasAttribute('enhanced')) {
// If this Custom Element was added dynamically with JavaScript then use the template contents to expand the element
// If this Custom Element was added dynamically with JavaScript then use the template contents to expand the element
if (!enhanced && !hasSlots) {
this.replaceChildren(this.template.content.cloneNode(true))
} else if (!enhanced && hasSlots) {
this.innerHTML = this.expandSlots(this)
}
}

toKebabCase(str) {
return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
}

styleTransform({ tag, tagName, scope }) {
const styles = this.parseCSS(tag.textContent)

if (scope === 'global') {
return styles
}

const rules = styles.cssRules
const sheet = new CSSStyleSheet();
for (let rule of rules) {
if (rule.conditionText) {
let selectorText = ''
for (let innerRule of rule.cssRules) {
let selectors = innerRule.selectorText.split(',')
selectorText = selectors.map(selector => {
return innerRule.cssText.replace(innerRule.selectorText, this.transform(selector, tagName))
}).join(',')
}
let type = this.getRuleType(rule)
sheet.insertRule(`${type} ${rule.conditionText} { ${selectorText}}`, sheet.cssRules.length)
} else {
let selectors = rule.selectorText.split(',')
let selectorText = selectors.map(selector => {
return this.transform(selector, tagName)
}).join(',')
sheet.insertRule(rule.cssText.replace(rule.selectorText, selectorText), sheet.cssRules.length)
}
}
return sheet
}

getRuleType(rule) {
switch (rule.constructor) {
case CSSContainerRule:
return '@container'
case CSSMediaRule:
return '@media'
case CSSSupportsRule:
return '@supports'
default:
return null
}
}

transform(input, tagName) {
let out = input
out = out.replace(/(::slotted)\(\s*(.+)\s*\)/, '$2')
.replace(/(:host-context)\(\s*(.+)\s*\)/, '$2 __TAGNAME__')
.replace(/(:host)\(\s*(.+)\s*\)/, '__TAGNAME__$2')
.replace(
/([[a-zA-Z0-9_-]*)(::part)\(\s*(.+)\s*\)/,
'$1 [part*="$3"][part*="$1"]')
.replace(':host', '__TAGNAME__')
out = /__TAGNAME__/.test(out) ? out.replace(/(.*)__TAGNAME__(.*)/, `$1${tagName}$2`) : `${tagName} ${out}`
return out
}

parseCSS(styleContent) {
const doc = document.implementation.createHTMLDocument("")
const styleElement = document.createElement("style")

styleElement.textContent = styleContent
doc.body.appendChild(styleElement)

return styleElement.sheet
}


expandSlots(here) {
const fragment = document.createElement('div')
fragment.innerHTML = here.innerHTML
fragment.attachShadow({ mode: 'open' }).appendChild(
here.template.content.cloneNode(true)
)

const children = Array.from(fragment.childNodes)
let unnamedSlot = {}
let namedSlots = {}

children.forEach(child => {
const slot = child.assignedSlot
if (slot) {
if (slot.name) {
if (!namedSlots[slot.name]) namedSlots[slot.name] = { slotNode: slot, contentToSlot: [] }
namedSlots[slot.name].contentToSlot.push(child)
} else {
if (!unnamedSlot["slotNode"]) unnamedSlot = { slotNode: slot, contentToSlot: [] }
unnamedSlot.contentToSlot.push(child)
}
}
})

// Named Slots
Object.entries(namedSlots).forEach(([name, slot]) => {
slot.slotNode.after(...namedSlots[name].contentToSlot)
slot.slotNode.remove()
})

// Unnamed Slot
unnamedSlot.slotNode?.after(...unnamedSlot.contentToSlot)
unnamedSlot.slotNode?.remove()

// Unused slots and default content
const unfilledUnnamedSlots = Array.from(fragment.shadowRoot.querySelectorAll('slot:not([name])'))
unfilledUnnamedSlots.forEach(slot => slot.remove())
const unfilledSlots = Array.from(fragment.shadowRoot.querySelectorAll('slot[name]'))
unfilledSlots.forEach(slot => {
const as = slot.getAttribute('as') || 'span'
const asElement = document.createElement(as)
while (slot.childNodes.length > 0) {
asElement.appendChild(slot.childNodes[0]);
}
slot.after(asElement)
slot.remove()
})

return fragment.shadowRoot.innerHTML
}

}
export default CustomElementMixin
Loading

0 comments on commit be90d19

Please sign in to comment.