Skip to content

Commit

Permalink
Merge branch 'refs/heads/dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
kgscialdone committed Jan 5, 2024
2 parents 50618b2 + 9398f20 commit cc69a4b
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 26 deletions.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Facet is a single-file web library that allows for the easy, declarative definit
## Installation
You can download `facet.min.js` from this repository and reference it locally, or retrieve it directly from a CDN like JSDelivr. Facet will automatically detect component definitions in your page's HTML and convert them into web components.
```html
<script src="https://cdn.jsdelivr.net/gh/kgscialdone/[email protected].1/facet.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/kgscialdone/[email protected].2/facet.min.js"></script>
```

## Defining Components
Expand Down Expand Up @@ -56,6 +56,12 @@ When inheriting attributes, you can use them as-is, rename them, filter them thr
<script>
function uppercase(string) { return string.toUpperCase() }
</script>

<!-- Or define your filter functions inside your component like this! -->
<!-- They'll be scoped to just that component and won't conflict with anything else. -->
<script filter="uppercase">
return value.toUpperCase()
</script>
```

In addition, attributes that are both observed and inherited will automatically update whenever the component's attribute is changed:
Expand Down Expand Up @@ -141,6 +147,28 @@ Mixins with the `prepend` attribute will prepend their contents instead of appen
</template>
```

## Advanced Features
You can define [customized built-in elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#customized_built-in_elements) with the `extends` attribute.
```html
<template component="lorem-ipsum" extends="p">
Lorem ipsum dolor sit amet... <slot></slot>
</template>

<p is="lorem-ipsum">is a pretty weird choice of filler text, if you think about it.</p>
```

You can define [form-associated custom elements](https://web.dev/articles/more-capable-form-controls) with the `forminput` attribute.
```html
<template component="inc-dec" forminput>
<script on="connect" once>host.innerText = host.value</script>
<button>+ <script on="click">host.innerText = ++host.value</script></button>
<span><slot></slot></span>
<button>- <script on="click">host.innerText = --host.value</script></button>
</template>

<inc-dec value="0"></inc-dec>
```

## Configuration Options
While Facet's defaults are designed to serve the majority of use cases out of the box, it does have a small handful of configuration options, which can be adjusted via attributes on the `<script>` tag that imports the Facet library.

Expand Down Expand Up @@ -191,6 +219,7 @@ While Facet strives to never require you to write Javascript for any of its core
| `facet.config.namespace` | The required namespace prefix for the `component` and `mixin` attributes.
| `facet.config.autoDiscover` | If false, skip automatic discovery of components/mixins on page load.
| `facet.config.defaultShadowMode` | The default shadow root mode for new components.
| `facet.mixins` | Information about all currently defined mixins.

Facet's source code is also lovingly commented with [JSDoc](https://jsdoc.app/), which keeps it lightweight and build-step free while still enabling Typescript users to rest easy about type safety when interacting with Facet's API.

Expand Down
109 changes: 86 additions & 23 deletions facet.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
// Facet v0.1.1 | https://github.com/kgscialdone/facet
// Facet v0.1.2 | https://github.com/kgscialdone/facet

/** Facet Javascript API */
const facet = new function() {
const mixins = {}, globalMixins = []
this.version = '0.1.1'
this.version = '0.1.2'

/**
* Define a Facet component. This is primarily for internal use; it can be called manually to define
Expand All @@ -14,17 +13,55 @@ const facet = new function() {
* @param {'open'|'closed'|'none'} [options.shadowMode='closed'] The shadow DOM mode to use (default: 'closed').
* @param {string[]} [options.observeAttrs=[]] A list of attribute names to observe (default: []).
* @param {string[]} [options.applyMixins=[]] A list of mixin names to include (default: []).
* @param {{[name:string]:(host:FacetComponent,root:(FacetComponent|ShadowRoot),value:string)=>string}} [options.localFilters={}] An object containing local filter functions (default: {}).
* @param {string} [options.extendsElement=] The tag name of the element type to extend if any (default: unset)
* @param {boolean} [options.formAssoc=false] If true, treat this custom element as a form element (default: false)
*/
this.defineComponent = function defineComponent(tagName, template, { shadowMode = 'closed', observeAttrs = [], applyMixins = [] }) {
const localMixins = new Set(applyMixins.concat(globalMixins).map(m=>mixins[m]))

window.customElements.define(tagName, class FacetComponent extends HTMLElement {
this.defineComponent = function defineComponent(tagName, template,
{ shadowMode = 'closed', observeAttrs = [], applyMixins = [], localFilters = {}, extendsElement, formAssoc = false }
) {
const extendsConstr = extendsElement ? document.createElement(extendsElement).constructor : HTMLElement
const extendsOptions = extendsElement ? { extends: extendsElement } : undefined

window.customElements.define(tagName, class FacetComponent extends extendsConstr {
static observedAttributes = observeAttrs
static formAssociated = formAssoc
#root = shadowMode !== 'none' ? this.attachShadow({ mode: shadowMode }) : this
#localFilters = {...localFilters}

constructor() {
super()

// Setup form associated mode
// This whole section is a mega kludge, but there isn't really a better way
if(formAssoc) {
let internals = this.attachInternals(), value
Object.defineProperties(this, {
internals: { value: internals, writable: false },
value: { get: () => value, set: newValue => internals.setFormValue(value = newValue) },

name: { get: () => this.getAttribute('name') },
form: { get: () => internals.form },
labels: { get: () => internals.labels },
validity: { get: () => internals.validity },
validationMessage: { get: () => internals.validationMessage },
willValidate: { get: () => internals.willValidate },

setFormValue: { value: (n,s) => internals.setFormValue(value = n, s), writable: false },
setValidity: { value: internals.setValidity.bind(internals), writable: false },
checkValidity: { value: internals.checkValidity.bind(internals), writable: false },
reportValidity: { value: internals.reportValidity.bind(internals), writable: false }
})
}
}

connectedCallback() {
const content = template.content.cloneNode(true)
for(let mixin of localMixins) content[mixin.attachPosition](mixin.template.content.cloneNode(true))
const mixins = Object.values(facet.mixins).filter(m => m.applyGlobally || applyMixins.includes(m.name))
for(let mixin of mixins) {
content[mixin.attachPosition](mixin.template.content.cloneNode(true))
Object.assign(this.#localFilters, mixin.localFilters)
}

// Attach <script on> event handlers
for(let script of content.querySelectorAll('script[on]')) {
Expand All @@ -43,7 +80,7 @@ const facet = new function() {
for(let el of content.querySelectorAll('[inherit]')) {
for(let attr of el.getAttribute('inherit').split(/\s+/g)) {
const [,ogname,rename,fn] = attr.match(/^([^\/>"'=]+)(?:>([^\/>"'=]+))?(?:\/(\w+))?$/)
const cv = this.getAttribute(ogname), filter = window[fn]
const cv = this.getAttribute(ogname), filter = this.#localFilters[fn]?.bind(this, this, this.#root) ?? window[fn]
if(cv) el.setAttribute(rename ?? ogname, filter?.(cv, undefined, el, this) ?? cv)

if(observeAttrs.includes(ogname))
Expand All @@ -54,16 +91,22 @@ const facet = new function() {
}
el.removeAttribute('inherit')
}


if(formAssoc) this.value = this.getAttribute('value')
this.#root.append(content)
this.#event('connect')
}

disconnectedCallback() { this.#event('disconnect') }
adoptedCallback() { this.#event('adopt') }
disconnectedCallback() { this.#event('disconnect') }
adoptedCallback() { this.#event('adopt') }
attributeChangedCallback(name, oldValue, newValue) { this.#event('attributeChanged', { name, oldValue, newValue }) }
formAssociatedCallback(form) { this.#event('formAssociate', { form }) }
formDisabledCallback(disabled) { this.#event('formDisable', { disabled }) }
formResetCallback() { this.#event('formReset') }
formStateRestoreCallback(state, mode) { this.#event('formStateRestore', { state, mode }) }

#event(n, d={}) { this.dispatchEvent(new CustomEvent(n, { detail: { ...d, component: this } })) }
})
}, extendsOptions)
}

/**
Expand All @@ -73,29 +116,49 @@ const facet = new function() {
* @param {Object} options Mixin options
* @param {boolean} [options.applyGlobally=false] If true, automatically applies this mixin to all components (default: false).
* @param {'prepend'|'append'} [options.attachPosition='append'] Determines whether to prepend or append the mixin's content (default: 'append').
* @param {{[name:string]:(host:FacetComponent,root:(FacetComponent|ShadowRoot),value:string)=>string}} [options.localFilters={}] An object containing local filter functions (default: {}).
*/
this.defineMixin = function defineMixin(name, template, { applyGlobally = false, attachPosition = 'append' }) {
mixins[name] = { template, attachPosition }
if(applyGlobally) globalMixins.push(name)
this.defineMixin = function defineMixin(name, template, options) {
(this.mixins ??= {})[name] = { ...options, name, template }
}

/**
* Discover and define `<template mixin>`s and `<template component>`s.
* @param {ParentNode} root The parent element to discover inside.
*/
this.discoverDeclarativeComponents = function discoverDeclarativeComponents(root) {
for(let template of root.querySelectorAll(`template[${facet.config.namespace}mixin]`))
this.defineMixin(template.getAttribute(`${facet.config.namespace}mixin`), template, {
let mixinSelector = `template[${facet.config.namespace}mixin]:not([defined])`
let cmpntSelector = `template[${facet.config.namespace}component]:not([defined])`

if(root.matches?.(mixinSelector)) processMixin(root)
if(root.matches?.(cmpntSelector)) processComponent(root)
for(let template of root.querySelectorAll(mixinSelector)) processMixin(template)
for(let template of root.querySelectorAll(cmpntSelector)) processComponent(template)

function processMixin(template) {
template.setAttribute('defined',true)
facet.defineMixin(template.getAttribute(`${facet.config.namespace}mixin`), template, {
applyGlobally: template.hasAttribute('global'),
attachPosition: template.hasAttribute('prepend') ? 'prepend' : 'append'
attachPosition: template.hasAttribute('prepend') ? 'prepend' : 'append',
localFilters: discoverLocalFilters(template),
})

for(let template of root.querySelectorAll(`template[${facet.config.namespace}component]`))
this.defineComponent(template.getAttribute(`${facet.config.namespace}component`), template, {
}
function processComponent(template) {
template.setAttribute('defined',true)
facet.defineComponent(template.getAttribute(`${facet.config.namespace}component`), template, {
shadowMode: template.getAttribute('shadow')?.toLowerCase() ?? facet.config.defaultShadowMode,
observeAttrs: template.getAttribute('observe')?.split(/\s+/g) ?? [],
applyMixins: template.getAttribute('mixins')?.split(/\s+/g) ?? []
applyMixins: template.getAttribute('mixins')?.split(/\s+/g) ?? [],
localFilters: discoverLocalFilters(template),
extendsElement: template.getAttribute('extends'),
formAssoc: template.hasAttribute('forminput'),
})
}
function discoverLocalFilters(template) {
return [...template.content.querySelectorAll('script[filter]')]
.map(script => { script.remove(); return [script.getAttribute('filter'), new Function('host', 'root', 'value', script.innerText)] })
.reduce((a,[k,v]) => { a[k] = v; return a }, {})
}
}

/** Configuration options */
Expand Down
Loading

0 comments on commit cc69a4b

Please sign in to comment.