diff --git a/index.html b/index.html index 8ec27abdf..301d02489 100644 --- a/index.html +++ b/index.html @@ -1,54 +1,123 @@ - - - - - - - - - + + + + + -
-
-
-
-
- + }"> + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + + +

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + + +
+ +
+ Yo: +
+ +
Query:
diff --git a/packages/alpinejs/src/alpine.js b/packages/alpinejs/src/alpine.js index 09c20106d..e4d386b2f 100644 --- a/packages/alpinejs/src/alpine.js +++ b/packages/alpinejs/src/alpine.js @@ -1,7 +1,7 @@ import { setReactivityEngine, disableEffectScheduling, reactive, effect, release, raw } from './reactivity' import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } from './directives' import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree, destroyTree, interceptInit } from './lifecycle' -import { mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation' +import { onElRemoved, onAttributeRemoved, mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation' import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope' import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator' import { transition } from './directives/x-transition' @@ -11,6 +11,7 @@ import { getBinding as bound, extractProp } from './utils/bind' import { debounce } from './utils/debounce' import { throttle } from './utils/throttle' import { setStyles } from './utils/styles' +import { entangle } from './entangle' import { nextTick } from './nextTick' import { walk } from './utils/walk' import { plugin } from './plugin' @@ -31,6 +32,7 @@ let Alpine = { startObservingMutations, stopObservingMutations, setReactivityEngine, + onAttributeRemoved, closestDataStack, skipDuringClone, onlyDuringClone, @@ -45,6 +47,7 @@ let Alpine = { mergeProxies, extractProp, findClosest, + onElRemoved, closestRoot, destroyTree, interceptor, // INTERNAL: not public API and is subject to change without major release. @@ -52,6 +55,7 @@ let Alpine = { setStyles, // INTERNAL mutateDom, directive, + entangle, throttle, debounce, evaluate, diff --git a/packages/alpinejs/src/directives/x-model.js b/packages/alpinejs/src/directives/x-model.js index 0e4e1fccc..f17e55e9b 100644 --- a/packages/alpinejs/src/directives/x-model.js +++ b/packages/alpinejs/src/directives/x-model.js @@ -106,8 +106,6 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => { } el._x_forceModelUpdate = (value) => { - value = value === undefined ? getValue() : value - // If nested model key is undefined, set the default value to empty string. if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = '' diff --git a/packages/alpinejs/src/directives/x-on.js b/packages/alpinejs/src/directives/x-on.js index cd997a339..210e2f17b 100644 --- a/packages/alpinejs/src/directives/x-on.js +++ b/packages/alpinejs/src/directives/x-on.js @@ -7,7 +7,7 @@ mapAttributes(startingWith('@', into(prefix('on:')))) directive('on', skipDuringClone((el, { value, modifiers, expression }, { cleanup }) => { let evaluate = expression ? evaluateLater(el, expression) : () => {} - + // Forward event listeners on portals. if (el.tagName.toLowerCase() === 'template') { if (! el._x_forwardEvents) el._x_forwardEvents = [] diff --git a/packages/alpinejs/src/entangle.js b/packages/alpinejs/src/entangle.js index 4d0661a34..f0621a486 100644 --- a/packages/alpinejs/src/entangle.js +++ b/packages/alpinejs/src/entangle.js @@ -9,7 +9,7 @@ export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: if (firstRun) { outer = outerGet() - innerSet(outer) + innerSet(JSON.parse(JSON.stringify(outer))) // We need to break internal references using parse/stringify... inner = innerGet() firstRun = false } else { @@ -24,7 +24,7 @@ export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: innerSet(outer) inner = outer // Assign inner to outer so that it can be serialized for diffing... } else { // If inner changed... - outerSet(inner) + outerSet(JSON.parse(JSON.stringify(inner))) // We need to break internal references using parse/stringify... outer = inner // Assign outer to inner so that it can be serialized for diffing... } } diff --git a/packages/alpinejs/src/evaluator.js b/packages/alpinejs/src/evaluator.js index 74e52e669..c123810b2 100644 --- a/packages/alpinejs/src/evaluator.js +++ b/packages/alpinejs/src/evaluator.js @@ -70,9 +70,9 @@ function generateFunctionFromString(expression, el) { // calling function so that we don't throw an error AND a "return" statement can b e used. let rightSideSafeExpression = 0 // Support expressions starting with "if" statements like: "if (...) doSomething()" - || /^[\n\s]*if.*\(.*\)/.test(expression) + || /^[\n\s]*if.*\(.*\)/.test(expression.trim()) // Support expressions starting with "let/const" like: "let foo = 'bar'" - || /^(let|const)\s/.test(expression) + || /^(let|const)\s/.test(expression.trim()) ? `(async()=>{ ${expression} })()` : expression diff --git a/packages/alpinejs/src/lifecycle.js b/packages/alpinejs/src/lifecycle.js index 6a62a61ab..cd9e59734 100644 --- a/packages/alpinejs/src/lifecycle.js +++ b/packages/alpinejs/src/lifecycle.js @@ -3,6 +3,7 @@ import { deferHandlingDirectives, directives } from "./directives" import { dispatch } from './utils/dispatch' import { walk } from "./utils/walk" import { warn } from './utils/warn' +import Alpine from "./alpine" let started = false diff --git a/packages/alpinejs/src/utils/bind.js b/packages/alpinejs/src/utils/bind.js index a857568ca..87722bc44 100644 --- a/packages/alpinejs/src/utils/bind.js +++ b/packages/alpinejs/src/utils/bind.js @@ -57,7 +57,7 @@ function bindInputValue(el, value) { // automatically. if (Number.isInteger(value)) { el.value = value - } else if (! Number.isInteger(value) && ! Array.isArray(value) && typeof value !== 'boolean' && ! [null, undefined].includes(value)) { + } else if (! Array.isArray(value) && typeof value !== 'boolean' && ! [null, undefined].includes(value)) { el.value = String(value) } else { if (Array.isArray(value)) { @@ -71,7 +71,7 @@ function bindInputValue(el, value) { } else { if (el.value === value) return - el.value = value + el.value = value === undefined ? '' : value } } diff --git a/packages/history/builds/module.js b/packages/history/builds/module.js index a7d9aba46..122f73ea5 100644 --- a/packages/history/builds/module.js +++ b/packages/history/builds/module.js @@ -1,3 +1,5 @@ import history from '../src/index.js' +import { track } from '../src/index.js' export default history +export { track } diff --git a/packages/history/src/index.js b/packages/history/src/index.js index f230d809a..23d35ba57 100644 --- a/packages/history/src/index.js +++ b/packages/history/src/index.js @@ -1,62 +1,105 @@ + export default function history(Alpine) { Alpine.magic('queryString', (el, { interceptor }) => { let alias + let alwaysShow = false + let usePush = false - return interceptor((initialValue, getter, setter, path, key) => { - let pause = false + return interceptor((initialSeedValue, getter, setter, path, key) => { let queryKey = alias || path - let value = initialValue - let url = new URL(window.location.href) + let { initial, replace, push, pop } = track(queryKey, initialSeedValue, alwaysShow) + + setter(initial) + + if (! usePush) { + Alpine.effect(() => replace(getter())) + } else { + Alpine.effect(() => push(getter())) + + pop(async newValue => { + setter(newValue) + + let tillTheEndOfTheMicrotaskQueue = () => Promise.resolve() - if (url.searchParams.has(queryKey)) { - value = url.searchParams.get(queryKey) + await tillTheEndOfTheMicrotaskQueue() // ...so that we preserve the internal lock... + }) } - setter(value) + return initial + }, func => { + func.alwaysShow = () => { alwaysShow = true; return func } + func.usePush = () => { usePush = true; return func } + func.as = key => { alias = key; return func } + }) + }) - let object = { value } + Alpine.history = { track } +} - url.searchParams.set(queryKey, value) +export function track(name, initialSeedValue, alwaysShow = false) { + let { has, get, set, remove } = queryStringUtils() - replace(url.toString(), path, object) + let url = new URL(window.location.href) + let isInitiallyPresentInUrl = has(url, name) + let initialValue = isInitiallyPresentInUrl ? get(url, name) : initialSeedValue + let initialValueMemo = JSON.stringify(initialValue) + let hasReturnedToInitialValue = (newValue) => JSON.stringify(newValue) === initialValueMemo - window.addEventListener('popstate', (e) => { - if (! e.state) return - if (! e.state.alpine) return + if (alwaysShow) url = set(url, name, initialValue) - Object.entries(e.state.alpine).forEach(([newKey, { value }]) => { - if (newKey !== key) return + replace(url, name, { value: initialValue }) - pause = true + let lock = false - Alpine.disableEffectScheduling(() => { - setter(value) - }) + let update = (strategy, newValue) => { + if (lock) return - pause = false - }) - }) + let url = new URL(window.location.href) - Alpine.effect(() => { - let value = getter() + if (! alwaysShow && ! isInitiallyPresentInUrl && hasReturnedToInitialValue(newValue)) { + url = remove(url, name) + } else { + url = set(url, name, newValue) + } - if (pause) return + strategy(url, name, { value: newValue}) + } - let object = { value } + return { + initial: initialValue, - let url = new URL(window.location.href) + replace(newValue) { // Update via replaceState... + update(replace, newValue) + }, - url.searchParams.set(queryKey, value) + push(newValue) { // Update via pushState... + update(push, newValue) + }, - push(url.toString(), path, object) - }) + pop(receiver) { // "popstate" handler... + window.addEventListener('popstate', (e) => { + if (! e.state || ! e.state.alpine) return - return value - }, func => { - func.as = key => { alias = key; return func } - }) - }) + Object.entries(e.state.alpine).forEach(([iName, { value: newValue }]) => { + if (iName !== name) return + + lock = true + + // Allow the "receiver" to be an async function in case a non-syncronous + // operation (like an ajax) requests needs to happen while preserving + // the "locking" mechanism ("lock = true" in this case)... + let result = receiver(newValue) + + if (result instanceof Promise) { + result.finally(() => lock = false) + } else { + lock = false + } + }) + }) + } + } } function replace(url, key, object) { @@ -64,13 +107,127 @@ function replace(url, key, object) { if (! state.alpine) state.alpine = {} - state.alpine[key] = object + state.alpine[key] = unwrap(object) - window.history.replaceState(state, '', url) + window.history.replaceState(state, '', url.toString()) } function push(url, key, object) { - let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} } + let state = { alpine: {...window.history.state.alpine, ...{[key]: unwrap(object)}} } + + window.history.pushState(state, '', url.toString()) +} + +function unwrap(object) { + return JSON.parse(JSON.stringify(object)) +} + +function queryStringUtils() { + return { + has(url, key) { + let search = url.search + + if (! search) return false + + let data = fromQueryString(search) + + return Object.keys(data).includes(key) + }, + get(url, key) { + let search = url.search - window.history.pushState(state, '', url) + if (! search) return false + + let data = fromQueryString(search) + + return data[key] + }, + set(url, key, value) { + let data = fromQueryString(url.search) + + data[key] = value + + url.search = toQueryString(data) + + return url + }, + remove(url, key) { + let data = fromQueryString(url.search) + + delete data[key] + + url.search = toQueryString(data) + + return url + }, + } } + +// This function converts JavaScript data to bracketed query string notation... +// { items: [['foo']] } -> "items[0][0]=foo" +function toQueryString(data) { + let isObjecty = (subject) => typeof subject === 'object' && subject !== null + + let buildQueryStringEntries = (data, entries = {}, baseKey = '') => { + Object.entries(data).forEach(([iKey, iValue]) => { + let key = baseKey === '' ? iKey : `${baseKey}[${iKey}]` + + if (! isObjecty(iValue)) { + entries[key] = encodeURIComponent(iValue) + .replaceAll('%20', '+') // Conform to RFC1738 + } else { + entries = {...entries, ...buildQueryStringEntries(iValue, entries, key)} + } + }) + + return entries + } + + let entries = buildQueryStringEntries(data) + + + return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join('&') +} + +// This function converts bracketed query string notation back to JS data... +// "items[0][0]=foo" -> { items: [['foo']] } +function fromQueryString(search) { + search = search.replace('?', '') + + if (search === '') return {} + + let insertDotNotatedValueIntoData = (key, value, data) => { + let [first, second, ...rest] = key.split('.') + + // We're at a leaf node, let's make the assigment... + if (! second) return data[key] = value + + // This is where we fill in empty arrays/objects allong the way to the assigment... + if (data[first] === undefined) { + data[first] = isNaN(second) ? {} : [] + } + + // Keep deferring assignment until the full key is built up... + insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]) + } + + let entries = search.split('&').map(i => i.split('=')) + + let data = {} + + entries.forEach(([key, value]) => { + value = decodeURIComponent(value.replaceAll('+', '%20')) + + if (! key.includes('[')) { + data[key] = value + } else { + // Convert to dot notation because it's easier... + let dotNotatedKey = key.replaceAll('[', '.').replaceAll(']', '') + + insertDotNotatedValueIntoData(dotNotatedKey, value, data) + } + }) + + return data +} + diff --git a/packages/morph/src/morph.js b/packages/morph/src/morph.js index 6ab20b504..3514b9ec4 100644 --- a/packages/morph/src/morph.js +++ b/packages/morph/src/morph.js @@ -101,6 +101,8 @@ export function morph(from, toHtml, options) { } function patchAttributes(from, to) { + if (from._x_transitioning) return + if (from._x_isShown && ! to._x_isShown) { return } diff --git a/packages/navigate/src/history.js b/packages/navigate/src/history.js index cd230682a..f294c06e9 100644 --- a/packages/navigate/src/history.js +++ b/packages/navigate/src/history.js @@ -9,7 +9,13 @@ export function updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks() { export function whenTheBackOrForwardButtonIsClicked(callback) { window.addEventListener('popstate', e => { - let { html } = fromSessionStorage(e) + let state = e.state || {} + + let alpine = state.alpine || {} + + if (! alpine._html) return + + let html = fromSessionStorage(alpine._html) callback(html) }) @@ -30,18 +36,20 @@ export function replaceUrl(url, html) { function updateUrl(method, url, html) { let key = (new Date).getTime() - tryToStoreInSession(key, JSON.stringify({ html: html })) + tryToStoreInSession(key, html) - let state = Object.assign(history.state || {}, { alpine: key }) + let state = history.state || {} + + if (! state.alpine) state.alpine = {} + + state.alpine._html = key // 640k character limit: history[method](state, document.title, url) } -export function fromSessionStorage(event) { - if (! event.state.alpine) return {} - - let state = JSON.parse(sessionStorage.getItem('alpine:'+event.state.alpine)) +export function fromSessionStorage(timestamp) { + let state = JSON.parse(sessionStorage.getItem('alpine:'+timestamp)) return state } @@ -52,7 +60,7 @@ function tryToStoreInSession(timestamp, value) { // (oldest first), until there's enough space to store // the new one. try { - sessionStorage.setItem('alpine:'+timestamp, value) + sessionStorage.setItem('alpine:'+timestamp, JSON.stringify(value)) } catch (error) { // 22 is Chrome, 1-14 is other browsers. if (! [22, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].includes(error.code)) return diff --git a/packages/navigate/src/index.js b/packages/navigate/src/index.js index cc120ad0a..8f65df72d 100644 --- a/packages/navigate/src/index.js +++ b/packages/navigate/src/index.js @@ -1,36 +1,56 @@ -import { transition } from "alpinejs/src/directives/x-transition" -import { finishAndHideProgressBar, showAndStartProgressBar } from "./bar" -import { fetchHtml } from "./fetch" import { updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks, updateUrlAndStoreLatestHtmlForFutureBackButtons, whenTheBackOrForwardButtonIsClicked } from "./history" -import { extractDestinationFromLink, hijackNewLinksOnThePage, whenALinkIsClicked, whenALinkIsHovered } from "./links" -import { swapCurrentPageWithNewHtml } from "./page" -import { putPersistantElementsBack, storePersistantElementsForLater } from "./persist" import { getPretchedHtmlOr, prefetchHtml, storeThePrefetchedHtmlForWhenALinkIsClicked } from "./prefetch" +import { createUrlObjectFromString, extractDestinationFromLink, whenThisLinkIsHoveredFor, whenThisLinkIsPressed } from "./links" import { restoreScrollPosition, storeScrollInformationInHtmlBeforeNavigatingAway } from "./scroll" +import { putPersistantElementsBack, storePersistantElementsForLater } from "./persist" +import { finishAndHideProgressBar, showAndStartProgressBar } from "./bar" +import { transition } from "alpinejs/src/directives/x-transition" +import { swapCurrentPageWithNewHtml } from "./page" +import { fetchHtml } from "./fetch" +import { prefix } from "alpinejs/src/directives" -let enablePrefetch = true let enablePersist = true let showProgressBar = true let restoreScroll = true let autofocus = false export default function (Alpine) { - updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks() + Alpine.navigate = (url) => { + navigateTo( + createUrlObjectFromString(url) + ) + } + + Alpine.addInitSelector(() => `[${prefix('navigate')}]`) + + Alpine.directive('navigate', (el, { value, expression, modifiers }, { evaluateLater, cleanup }) => { + let shouldPrefetchOnHover = modifiers.includes('hover') + + shouldPrefetchOnHover && whenThisLinkIsHoveredFor(el, 60, () => { + let destination = extractDestinationFromLink(el) - enablePrefetch && whenALinkIsHovered((el) => { - let forDestination = extractDestinationFromLink(el) + prefetchHtml(destination, html => { + storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination) + }) + }) - prefetchHtml(forDestination, html => { - storeThePrefetchedHtmlForWhenALinkIsClicked(html, forDestination) + whenThisLinkIsPressed(el, (whenItIsReleased) => { + let destination = extractDestinationFromLink(el) + + prefetchHtml(destination, html => { + storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination) + }) + + whenItIsReleased(() => { + navigateTo(destination) + }) }) }) - whenALinkIsClicked((el) => { + function navigateTo(destination) { showProgressBar && showAndStartProgressBar() - let fromDestination = extractDestinationFromLink(el) - - fetchHtmlOrUsePrefetchedHtml(fromDestination, html => { + fetchHtmlOrUsePrefetchedHtml(destination, html => { restoreScroll && storeScrollInformationInHtmlBeforeNavigatingAway() showProgressBar && finishAndHideProgressBar() @@ -43,15 +63,11 @@ export default function (Alpine) { swapCurrentPageWithNewHtml(html, () => { enablePersist && putPersistantElementsBack() - // Added setTimeout here to detect a currently hovered prefetch link... - // (hack for laracon) - setTimeout(() => hijackNewLinksOnThePage()) - restoreScroll && restoreScrollPosition() fireEventForOtherLibariesToHookInto() - updateUrlAndStoreLatestHtmlForFutureBackButtons(html, fromDestination) + updateUrlAndStoreLatestHtmlForFutureBackButtons(html, destination) andAfterAllThis(() => { autofocus && autofocusElementsWithTheAutofocusAttribute() @@ -59,10 +75,9 @@ export default function (Alpine) { nowInitializeAlpineOnTheNewPage(Alpine) }) }) - }) }) - }) + } whenTheBackOrForwardButtonIsClicked((html) => { // @todo: see if there's a way to update the current HTML BEFORE @@ -76,8 +91,6 @@ export default function (Alpine) { swapCurrentPageWithNewHtml(html, andThen => { enablePersist && putPersistantElementsBack() - hijackNewLinksOnThePage() - restoreScroll && restoreScrollPosition() fireEventForOtherLibariesToHookInto() @@ -91,6 +104,12 @@ export default function (Alpine) { }) }) + + // Because DOMContentLoaded is fired on first load, + // we should fire alpine:navigated as a replacement as well... + setTimeout(() => { + fireEventForOtherLibariesToHookInto() + }) } function fetchHtmlOrUsePrefetchedHtml(fromDestination, callback) { diff --git a/packages/navigate/src/links.js b/packages/navigate/src/links.js index 1273a2443..6322fcf76 100644 --- a/packages/navigate/src/links.js +++ b/packages/navigate/src/links.js @@ -1,51 +1,52 @@ -let handleLinkClick = () => {} -let handleLinkHover = () => {} +export function whenThisLinkIsClicked(el, callback) { + el.addEventListener('click', e => { + e.preventDefault() -export function whenALinkIsClicked(callback) { - handleLinkClick = callback - - initializeLinksForClicking() + callback(el) + }) } -export function whenALinkIsHovered(callback) { - handleLinkHover = callback +export function whenThisLinkIsPressed(el, callback) { + el.addEventListener('click', e => e.preventDefault()) - initializeLinksForHovering() -} + el.addEventListener('mousedown', e => { + e.preventDefault() -export function extractDestinationFromLink(linkEl) { - return new URL(linkEl.getAttribute('href'), document.baseURI) -} + callback((whenReleased) => { + let handler = e => { + e.preventDefault() -export function hijackNewLinksOnThePage() { - initializeLinksForClicking() - initializeLinksForHovering() -} + whenReleased() -function initializeLinksForClicking() { - getLinks().forEach(el => { - el.addEventListener('click', e => { - e.preventDefault() + el.removeEventListener('mouseup', handler) + } - handleLinkClick(el) + el.addEventListener('mouseup', handler) }) }) } -function initializeLinksForHovering() { - getLinks() - .filter(i => i.hasAttribute('wire:navigate.prefetch')) - .forEach(el => { - el.addEventListener('mouseenter', e => { - handleLinkHover(el) - }) - }) +export function whenThisLinkIsHoveredFor(el, ms = 60, callback) { + el.addEventListener('mouseenter', e => { + let timeout = setTimeout(() => { + + }, ms) + + let handler = () => { + clear + el.removeEventListener('mouseleave', handler) + } + + el.addEventListener('mouseleave', handler) + callback(e) + }) } -function getLinks() { - return Array.from(document.links) - .filter(i => i.hasAttribute('wire:navigate') - || i.hasAttribute('wire:navigate.prefetch')) +export function extractDestinationFromLink(linkEl) { + return createUrlObjectFromString(linkEl.getAttribute('href')) } +export function createUrlObjectFromString(urlString) { + return new URL(urlString, document.baseURI) +} diff --git a/packages/navigate/src/page.js b/packages/navigate/src/page.js index 8c68156ee..ec41265d1 100644 --- a/packages/navigate/src/page.js +++ b/packages/navigate/src/page.js @@ -6,6 +6,9 @@ export function swapCurrentPageWithNewHtml(html, andThen) { let newHead = document.adoptNode(newDocument.head) mergeNewHead(newHead) + + // mergeNewHead(newHead) + prepNewScriptTagsToRun(newBody) transitionOut(document.body) @@ -42,7 +45,7 @@ function transitionIn(body) { function prepNewScriptTagsToRun(newBody) { newBody.querySelectorAll('script').forEach(i => { - if (i.hasAttribute('x-navigate:ignore')) return + if (i.hasAttribute('data-navigate-once')) return i.replaceWith(cloneScriptTag(i)) }) @@ -54,9 +57,15 @@ function mergeNewHead(newHead) { // Only add scripts and styles that aren't already loaded on the page. let garbageCollector = document.createDocumentFragment() - for (child of Array.from(newHead.children)) { + for (let child of Array.from(newHead.children)) { if (isAsset(child)) { if (! headChildrenHtmlLookup.includes(child.outerHTML)) { + if (isTracked(child)) { + setTimeout(() => window.location.reload()) + + return + } + if (isScript(child)) { document.head.appendChild(cloneScriptTag(child)) } else { @@ -71,12 +80,12 @@ function mergeNewHead(newHead) { // How to free up the garbage collector? // Remove existing non-asset elements like meta, base, title, template. - for (child of Array.from(document.head.children)) { + for (let child of Array.from(document.head.children)) { if (! isAsset(child)) child.remove() } // Add new non-asset elements left over in the new head element. - for (child of Array.from(newHead.children)) { + for (let child of Array.from(newHead.children)) { document.head.appendChild(child) } } @@ -94,13 +103,17 @@ function cloneScriptTag(el) { return script } -function isAsset (el) { +function isTracked(el) { + return el.hasAttribute('data-navigate-track') +} + +function isAsset(el) { return (el.tagName.toLowerCase() === 'link' && el.getAttribute('rel').toLowerCase() === 'stylesheet') || el.tagName.toLowerCase() === 'style' || el.tagName.toLowerCase() === 'script' } -function isScript (el) { +function isScript(el) { return el.tagName.toLowerCase() === 'script' } diff --git a/packages/navigate/src/persist.js b/packages/navigate/src/persist.js index 79bb0d4b3..e56d99fa3 100644 --- a/packages/navigate/src/persist.js +++ b/packages/navigate/src/persist.js @@ -5,8 +5,8 @@ let els = {} export function storePersistantElementsForLater() { els = {} - document.querySelectorAll('[x-navigate\\:persist]').forEach(i => { - els[i.getAttribute('x-navigate:persist')] = i + document.querySelectorAll('[x-persist]').forEach(i => { + els[i.getAttribute('x-persist')] = i Alpine.mutateDom(() => { i.remove() @@ -15,8 +15,8 @@ export function storePersistantElementsForLater() { } export function putPersistantElementsBack() { - document.querySelectorAll('[x-navigate\\:persist]').forEach(i => { - let old = els[i.getAttribute('x-navigate:persist')] + document.querySelectorAll('[x-persist]').forEach(i => { + let old = els[i.getAttribute('x-persist')] old._x_wasPersisted = true diff --git a/packages/ui/package.json b/packages/ui/package.json index 0d73a701a..1f7afe09e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,10 +13,5 @@ "main": "dist/module.cjs.js", "module": "dist/module.esm.js", "unpkg": "dist/cdn.min.js", - "devDependencies": { - "alpinejs": "file:../alpinejs" - }, - "peerDependencies": { - "alpinejs": "^3.10.0" - } + "devDependencies": {} } diff --git a/packages/ui/src/combobox.js b/packages/ui/src/combobox.js index 595d851af..b0ac15b72 100644 --- a/packages/ui/src/combobox.js +++ b/packages/ui/src/combobox.js @@ -96,7 +96,7 @@ function handleRoot(el, Alpine) { this.__nullable = Alpine.extractProp(el, 'nullable', false) this.__compareBy = Alpine.extractProp(el, 'by') - this.__context = generateContext(this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst()) + this.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst()) let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null) @@ -109,7 +109,7 @@ function handleRoot(el, Alpine) { Alpine.effect(() => { // Everytime the value changes, we need to re-render the hidden inputs, // if a user passed the "name" prop... - this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value) + this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value) }) }) }, diff --git a/packages/ui/src/list-context.js b/packages/ui/src/list-context.js index 46f0ba464..fd51f8450 100644 --- a/packages/ui/src/list-context.js +++ b/packages/ui/src/list-context.js @@ -1,6 +1,5 @@ -import Alpine from "alpinejs"; -export function generateContext(multiple, orientation, activateSelectedOrFirst) { +export function generateContext(Alpine, multiple, orientation, activateSelectedOrFirst) { return { /** * Main state... @@ -326,7 +325,7 @@ function keyByValue(object, value) { return Object.keys(object).find(key => object[key] === value) } -export function renderHiddenInputs(el, name, value) { +export function renderHiddenInputs(Alpine, el, name, value) { // Create input elements... let newInputs = generateInputs(name, value) diff --git a/packages/ui/src/listbox.js b/packages/ui/src/listbox.js index 24e20fc52..72f099cd9 100644 --- a/packages/ui/src/listbox.js +++ b/packages/ui/src/listbox.js @@ -100,7 +100,7 @@ function handleRoot(el, Alpine) { this.__compareBy = Alpine.extractProp(el, 'by') this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical' - this.__context = generateContext(this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst()) + this.__context = generateContext(Alpine, this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst()) let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null) diff --git a/tests/cypress/integration/plugins/history.spec.js b/tests/cypress/integration/plugins/history.spec.js index 7fd7820c7..8c1bc9524 100644 --- a/tests/cypress/integration/plugins/history.spec.js +++ b/tests/cypress/integration/plugins/history.spec.js @@ -1,84 +1,129 @@ import { haveText, html, test } from '../../utils' -test('can go back and forth', +test('value is reflected in query string upon changing', [html`
+

Dec

`], ({ get, url, go }) => { get('span').should(haveText('1')) - url().should('include', '?count=1') + url().should('not.include', '?count=1') get('button').click() get('span').should(haveText('2')) url().should('include', '?count=2') - go('back') + get('button').click() + get('span').should(haveText('3')) + url().should('include', '?count=3') + get('h1').click() + get('h1').click() + get('span').should(haveText('1')) + url().should('not.include', '?count=1') + }, +) + +test('can configure always making the query string value present', + [html` +
+ +

Dec

+ +
+ `], + ({ get, url, go }) => { get('span').should(haveText('1')) url().should('include', '?count=1') - go('forward') + get('button').click() get('span').should(haveText('2')) url().should('include', '?count=2') + get('h1').click() + get('span').should(haveText('1')) + url().should('include', '?count=1') }, ) -test('property is set from the query string on load', +test('value is persisted across requests', [html`
`], - ({ get, url }, reload) => { + ({ get, url, go }, reload) => { get('span').should(haveText('1')) - url().should('include', '?count=1') + url().should('not.include', '?count=1') get('button').click() get('span').should(haveText('2')) url().should('include', '?count=2') + reload() + + url().should('include', '?count=2') get('span').should(haveText('2')) }, ) -test('can use a query string key alias', +test('can provide an alias', [html` -
+
`], - ({ get, url }, reload) => { + ({ get, url, go }) => { get('span').should(haveText('1')) - url().should('include', '?foo=1') + url().should('not.include', '?tnuoc=1') get('button').click() get('span').should(haveText('2')) - url().should('include', '?foo=2') - reload() + url().should('include', '?tnuoc=2') + }, +) + +test('can use pushState', + [html` +
+ + +
+ `], + ({ get, url, go }) => { + get('span').should(haveText('1')) + url().should('not.include', '?count=1') + get('button').click() get('span').should(haveText('2')) + url().should('include', '?count=2') + go('back') + get('span').should(haveText('1')) + url().should('not.include', '?count=1') + go('forward') + get('span').should(haveText('2')) + url().should('include', '?count=2') }, ) test('can go back and forth with multiple components', [html` -
+
-
+
`], ({ get, url, go }) => { get('#foo span').should(haveText('1')) - url().should('include', 'foo=1') + url().should('not.include', 'foo=1') get('#foo button').click() get('#foo span').should(haveText('2')) url().should('include', 'foo=2') get('#bar span').should(haveText('1')) - url().should('include', 'bar=1') + url().should('not.include', 'bar=1') get('#bar button').click() get('#bar span').should(haveText('2')) url().should('include', 'bar=2') @@ -86,15 +131,87 @@ test('can go back and forth with multiple components', go('back') get('#bar span').should(haveText('1')) - url().should('include', 'bar=1') + url().should('not.include', 'bar=1') get('#foo span').should(haveText('2')) url().should('include', 'foo=2') go('back') get('#bar span').should(haveText('1')) - url().should('include', 'bar=1') + url().should('not.include', 'bar=1') get('#foo span').should(haveText('1')) - url().should('include', 'foo=1') + url().should('not.include', 'foo=1') + }, +) + +test('supports arrays', + [html` +
+ + +
+ `], + ({ get, url, go }, reload) => { + get('span').should(haveText('["foo"]')) + url().should('not.include', '?items') + get('button').click() + get('span').should(haveText('["foo","bar"]')) + url().should('include', '?items[0]=foo&items[1]=bar') + reload() + url().should('include', '?items[0]=foo&items[1]=bar') + get('span').should(haveText('["foo","bar"]')) + }, +) + +test('supports deep arrays', + [html` +
+ + +
+ `], + ({ get, url, go }, reload) => { + get('span').should(haveText('["foo",["bar","baz"]]')) + url().should('not.include', '?items') + get('button').click() + get('span').should(haveText('["foo",["bar","baz","bob"]]')) + url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob') + reload() + url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob') + get('span').should(haveText('["foo",["bar","baz","bob"]]')) + }, +) + +test('supports objects', + [html` +
+ + +
+ `], + ({ get, url, go }, reload) => { + get('span').should(haveText('{"foo":"bar"}')) + url().should('not.include', '?items') + get('button').click() + get('span').should(haveText('{"foo":"bar","bob":"lob"}')) + url().should('include', '?items[foo]=bar&items[bob]=lob') + reload() + url().should('include', '?items[foo]=bar&items[bob]=lob') + get('span').should(haveText('{"foo":"bar","bob":"lob"}')) + }, +) + +test('encodes values according to RFC 1738 (plus signs for spaces)', + [html` +
+ +
+ `], + ({ get, url, go }, reload) => { + url().should('include', '?foo=hey%26there&bar=hey+there') + get('span').should(haveText('"hey&there""hey there"')) + reload() + url().should('include', '?foo=hey%26there&bar=hey+there') + get('span').should(haveText('"hey&there""hey there"')) }, )