Created
December 4, 2018 12:39
-
-
Save danieldiekmeier/5522a4b04468908d86b95720ef9877d6 to your computer and use it in GitHub Desktop.
A basic version of Turbolinks + Stimulus, in blazing small 2kb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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