Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A basic version of Turbolinks + Stimulus, in blazing small 2kb
import mitt from 'mitt'
const routeCache = new Map()
const hotCache = new Map()
const instanceCache = new Map()
let latestRequest = null
let isShowingPreview = false
let prevRoute
const eventBus = mitt()
eventBus.on('*', (...args) => {
console.log(...(args.filter(Boolean)))
})
window.addEventListener('popstate', event => {
console.log('popstate', event)
visit(document.location.pathname, { action: 'replace' })
})
export { eventBus }
export function start ({ loadController }) {
prevRoute = window.location.pathname
document.addEventListener('touchstart', event => {
console.log('touchstart')
event.preventDefault()
event.stopPropagation()
const linkEl = closest(event.target, 'a')
if (linkEl) {
event.preventDefault()
}
event.target.addEventListener('click', event => event.preventDefault(), { once: true })
event.target.addEventListener('touchend', event => {
console.log('touchend')
event.preventDefault()
// insertContent(link, promise)
}, { once: true })
})
document.addEventListener('mousedown', event => {
console.log('mousedown')
const linkEl = closest(event.target, 'a')
if (linkEl) {
event.preventDefault()
const link = linkEl.getAttribute('href')
const promise = loadContent(link)
event.target.addEventListener('click', event => event.preventDefault(), { once: true })
event.target.addEventListener('mouseup', event => {
event.preventDefault()
insertContent(link, promise)
}, { once: true })
}
})
connectControllers(loadController)
const target = document.querySelector('body')
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type !== 'childList') return
const parentNode = mutation.target
mutation.removedNodes.forEach(removedNode => {
if (removedNode.nodeType !== Node.ELEMENT_NODE) return
selectChildrenAndSelf(removedNode, '[data-controller]').forEach(el => {
if (instanceCache.has(el)) {
instanceCache.get(el)._disconnect()
instanceCache.delete(el)
}
})
})
instanceCache.forEach((instance, element) => {
if (element.contains(parentNode)) {
instance.installTargets()
instance.installActions()
}
})
})
connectControllers(loadController)
})
const config = { attributes: true, childList: true, subtree: true, characterData: true }
observer.observe(target, config)
eventBus.emit('load')
}
function connectControllers (loadController) {
document.querySelectorAll('[data-controller]').forEach(async controllerEl => {
if (instanceCache.has(controllerEl)) {
return
}
const controllerName = controllerEl.getAttribute('data-controller')
loadController(controllerName).then(Controller => {
if (!Controller) return
const instance = connectController(controllerEl, Controller)
instanceCache.set(controllerEl, instance)
})
})
}
function connectController (el, Controller) {
return new Controller(el)
}
async function loadContent (link) {
eventBus.emit('preload:before')
latestRequest = link
hotCache.delete(link)
const response = await fetch(link)
const content = await response.text()
routeCache.set(link, content)
hotCache.set(link, content)
eventBus.emit('preload:after')
}
async function insertContent (link, promise, options = {}) {
eventBus.emit('insert:before')
instanceCache.forEach((instance, element) => {
instance._disconnect()
instanceCache.delete(element)
})
routeCache.set(prevRoute, document.documentElement.outerHTML)
if (options.action === 'replace') {
window.history.replaceState({}, '', link)
} else {
window.history.pushState({}, '', link)
}
if (!hotCache.has(link) && routeCache.has(link)) {
updateBody(link)
eventBus.emit('insert:preview')
isShowingPreview = true
}
await promise
if (latestRequest !== link) return
updateBody(link)
eventBus.emit('insert:after')
isShowingPreview = false
prevRoute = link
if (!options.keepPosition) {
window.scroll(0, 0)
}
// if (options.hash) {
// setInterval(() => {
// window.location.hash = options.hash
// }, 1000)
// }
}
export function visit (link, options) {
return insertContent(
link,
loadContent(link),
options
)
}
export function isPreview () {
return isShowingPreview
}
function extractTitle (content) {
return content.match(/<title.*?>(.*)<\/title>/s)[1]
}
function extractBody (content) {
return content.match(/<body.*?>(.*)<\/body>/s)[1]
}
function updateBody (link) {
document.title = extractTitle(routeCache.get(link))
document.body.innerHTML = extractBody(routeCache.get(link))
}
export async function reload ({ hash } = {}) {
clearCache(document.location.pathname)
await visit(document.location.pathname, {
action: 'replace',
keepPosition: true,
hash
})
// if (hash) {
// console.log('set hash', hash)
// window.location.hash = hash
// }
}
export function clearCache (key) {
if (key) {
routeCache.delete(key)
} else {
routeCache.clear()
}
}
function selectChildrenAndSelf (parent, selector) {
let targets = Array.from(parent.querySelectorAll(selector))
if (parent.matches(selector)) {
targets = [parent].concat(targets)
}
return targets
}
export class Controller {
constructor (el) {
this.el = el
this.controllerName = el.getAttribute('data-controller')
this.actionCache = new Map()
this.installTargets()
this.installActions()
this.data = new DataHelper(el, this.controllerName)
this.connect()
}
_disconnect () {
this.disconnect()
}
disconnect () {
// provide your own implementation inside your sublasses
}
connect () {
// provide your own implementation inside your sublasses
}
installTargets () {
if (!this.constructor.targets) return
this.constructor.targets.forEach(target => {
const selector = `[data-target="${this.controllerName}.${target}"]`
const targets = selectChildrenAndSelf(this.el, selector)
this[`${target}Targets`] = targets
this[`${target}Target`] = targets[0]
const titled = target.slice(0, 1).toUpperCase() + target.slice(1)
this[`has${titled}Target`] = targets.length > 0
})
}
installActions () {
const selector = `[data-action*="${this.controllerName}#"]`
const actionTargets = selectChildrenAndSelf(this.el, selector)
actionTargets.forEach(actionEl => {
const dataAction = actionEl.getAttribute('data-action')
const [eventName, action] = parseAction(actionEl, dataAction)
if (!action) return
// console.log(actionEl, eventName, action)
if (this.actionCache.has(actionEl)) return
const actionFn = event => this[action].bind(this)(event)
actionEl.addEventListener(eventName, actionFn)
this.actionCache.set(actionEl, eventName + action)
})
}
}
class DataHelper {
constructor (el, controllerName) {
this.el = el
this.controllerName = controllerName
}
get (key) {
return this.el.getAttribute(`data-${this.controllerName}-${key}`)
}
set (key, value) {
this.el.setAttribute(`data-${this.controllerName}-${key}`, value)
}
}
const actionDefaults = [
['a', 'click'],
['button', 'click'],
['form', 'submit'],
['input[type=submit]', 'click'],
['input', 'change'],
['select', 'change'],
['textarea', 'change']
]
function parseAction (actionEl, actionStr) {
const matches = actionStr.match(/((.*?)->)?(.*?#)(.*)/)
if (!matches) return []
if (!matches[4]) return []
let defaultAction = null
actionDefaults.some(([selector, actionName]) => {
if (actionEl.matches(selector)) {
defaultAction = actionName
}
})
if (!matches[2] && defaultAction) {
return [defaultAction, matches[4]]
}
return [matches[2], matches[4]]
}
export function closest (element, selector) {
while (element && element !== document) {
if (element.matches(selector)) return element
element = element.parentNode
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment