phoenix_live_view
Advanced tools
Comparing version 1.0.2 to 1.0.3
@@ -21,2 +21,3 @@ import { | ||
detectDuplicateIds, | ||
detectInvalidStreamInserts, | ||
isCid | ||
@@ -30,36 +31,3 @@ } from "./utils" | ||
export default class DOMPatch { | ||
static patchWithClonedTree(container, clonedTree, liveSocket){ | ||
let focused = liveSocket.getActiveElement() | ||
let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {} | ||
let phxUpdate = liveSocket.binding(PHX_UPDATE) | ||
let externalFormTriggered = null | ||
morphdom(container, clonedTree, { | ||
childrenOnly: false, | ||
onBeforeElUpdated: (fromEl, toEl) => { | ||
DOM.syncPendingAttrs(fromEl, toEl) | ||
// we cannot morph locked children | ||
if(!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)){ return false } | ||
if(DOM.isIgnored(fromEl, phxUpdate)){ return false } | ||
if(focused && focused.isSameNode(fromEl) && DOM.isFormInput(fromEl)){ | ||
DOM.mergeFocusedInput(fromEl, toEl) | ||
return false | ||
} | ||
if(DOM.isNowTriggerFormExternal(toEl, liveSocket.binding(PHX_TRIGGER_ACTION))){ | ||
externalFormTriggered = toEl | ||
} | ||
} | ||
}) | ||
if(externalFormTriggered){ | ||
liveSocket.unload() | ||
// use prototype's submit in case there's a form control with name or id of "submit" | ||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit | ||
Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered) | ||
} | ||
liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd)) | ||
} | ||
constructor(view, container, id, html, streams, targetCID){ | ||
constructor(view, container, id, html, streams, targetCID, opts={}){ | ||
this.view = view | ||
@@ -84,2 +52,4 @@ this.liveSocket = view.liveSocket | ||
} | ||
this.withChildren = opts.withChildren || opts.undoRef || false | ||
this.undoRef = opts.undoRef | ||
} | ||
@@ -121,3 +91,3 @@ | ||
function morph(targetContainer, source, withChildren=false){ | ||
function morph(targetContainer, source, withChildren=this.withChildren){ | ||
let morphCallbacks = { | ||
@@ -254,3 +224,4 @@ // normally, we are running with childrenOnly, as the patch HTML for a LV | ||
let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl) | ||
if(fromEl.hasAttribute(PHX_REF_SRC)){ | ||
// only perform the clone step if this is not a patch that unlocks | ||
if(fromEl.hasAttribute(PHX_REF_SRC) && fromEl.getAttribute(PHX_REF_LOCK) != this.undoRef){ | ||
if(DOM.isUploadInput(fromEl)){ | ||
@@ -349,2 +320,3 @@ DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) | ||
detectDuplicateIds() | ||
detectInvalidStreamInserts(this.streamInserts) | ||
// warn if there are any inputs named "id" | ||
@@ -351,0 +323,0 @@ Array.from(document.querySelectorAll("input[name=id]")).forEach(node => { |
@@ -13,2 +13,3 @@ import { | ||
PHX_REF_SRC, | ||
PHX_REF_LOCK, | ||
PHX_PENDING_ATTRS, | ||
@@ -152,3 +153,3 @@ PHX_ROOT_ID, | ||
parentCids.add(cid) | ||
this.all(parent, `[${PHX_COMPONENT}]`) | ||
this.filterWithinSameLiveView(this.all(parent, `[${PHX_COMPONENT}]`), parent) | ||
.map(el => parseInt(el.getAttribute(PHX_COMPONENT))) | ||
@@ -550,2 +551,6 @@ .forEach(childCID => childrenCids.add(childCID)) | ||
ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)) | ||
}, | ||
isLocked(el){ | ||
return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK) | ||
} | ||
@@ -552,0 +557,0 @@ } |
@@ -14,2 +14,11 @@ import { | ||
export default class ElementRef { | ||
static onUnlock(el, callback){ | ||
if(!DOM.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)){ return callback() } | ||
const closestLock = el.closest(`[${PHX_REF_LOCK}]`) | ||
const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK) | ||
closestLock.addEventListener(`phx:undo-lock:${ref}`, () => { | ||
callback() | ||
}, {once: true}) | ||
} | ||
constructor(el){ | ||
@@ -16,0 +25,0 @@ this.el = el |
@@ -50,4 +50,22 @@ import { | ||
this.focusEnd = this.el.lastElementChild | ||
this.focusStart.addEventListener("focus", () => ARIA.focusLast(this.el)) | ||
this.focusEnd.addEventListener("focus", () => ARIA.focusFirst(this.el)) | ||
this.focusStart.addEventListener("focus", (e) => { | ||
if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){ | ||
// Handle focus entering from outside (e.g. Tab when body is focused) | ||
// https://github.com/phoenixframework/phoenix_live_view/issues/3636 | ||
const nextFocus = e.target.nextElementSibling | ||
ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus) | ||
} else { | ||
ARIA.focusLast(this.el) | ||
} | ||
}) | ||
this.focusEnd.addEventListener("focus", (e) => { | ||
if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){ | ||
// Handle focus entering from outside (e.g. Shift+Tab when body is focused) | ||
// https://github.com/phoenixframework/phoenix_live_view/issues/3636 | ||
const nextFocus = e.target.previousElementSibling | ||
ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus) | ||
} else { | ||
ARIA.focusFirst(this.el) | ||
} | ||
}) | ||
this.el.addEventListener("phx:show-end", () => this.el.focus()) | ||
@@ -54,0 +72,0 @@ if(window.getComputedStyle(this.el).display !== "none"){ |
@@ -378,3 +378,5 @@ /** Initializes the LiveSocket | ||
let view = this.newRootView(rootEl) | ||
view.setHref(this.getHref()) | ||
// stickies cannot be mounted at the router and therefore should not | ||
// get a href set on them | ||
if(!DOM.isPhxSticky(rootEl)){ view.setHref(this.getHref()) } | ||
view.join() | ||
@@ -697,3 +699,3 @@ if(rootEl.hasAttribute(PHX_MAIN)){ this.main = view } | ||
if(!this.registerNewLocation(window.location)){ return } | ||
let {type, backType, id, root, scroll, position} = event.state || {} | ||
let {type, backType, id, scroll, position} = event.state || {} | ||
let href = window.location.href | ||
@@ -712,11 +714,7 @@ | ||
this.requestDOMUpdate(() => { | ||
const callback = () => { this.maybeScroll(scroll) } | ||
if(this.main.isConnected() && (type === "patch" && id === this.main.id)){ | ||
this.main.pushLinkPatch(event, href, null, () => { | ||
this.maybeScroll(scroll) | ||
}) | ||
this.main.pushLinkPatch(event, href, null, callback) | ||
} else { | ||
this.replaceMain(href, null, () => { | ||
if(root){ this.replaceRootHistory() } | ||
this.maybeScroll(scroll) | ||
}) | ||
this.replaceMain(href, null, callback) | ||
} | ||
@@ -842,11 +840,2 @@ }) | ||
replaceRootHistory(){ | ||
Browser.pushState("replace", { | ||
root: true, | ||
type: "patch", | ||
id: this.main.id, | ||
position: this.currentHistoryPosition // Preserve current position | ||
}) | ||
} | ||
registerNewLocation(newLocation){ | ||
@@ -853,0 +842,0 @@ let {pathname, search} = this.currentLocation |
@@ -26,2 +26,13 @@ import { | ||
export function detectInvalidStreamInserts(inserts){ | ||
const errors = new Set() | ||
Object.keys(inserts).forEach((id) => { | ||
const streamEl = document.getElementById(id) | ||
if(streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream"){ | ||
errors.add(`The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`) | ||
} | ||
}) | ||
errors.forEach(error => console.error(error)) | ||
} | ||
export let debug = (view, kind, msg, obj) => { | ||
@@ -28,0 +39,0 @@ if(view.liveSocket.isDebugEnabled()){ |
@@ -321,4 +321,8 @@ import { | ||
if(this.isMain() && window.history.state === null){ | ||
// set initial history entry if this is the first page load | ||
this.liveSocket.replaceRootHistory() | ||
// set initial history entry if this is the first page load (no history) | ||
Browser.pushState("replace", { | ||
type: "patch", | ||
id: this.id, | ||
position: this.liveSocket.currentHistoryPosition | ||
}) | ||
} | ||
@@ -701,2 +705,5 @@ | ||
// only ever try to add hooks to elements owned by this view | ||
if(el.getAttribute && !this.ownsElement(el)){ return } | ||
if(hookElId && !this.viewHooks[hookElId]){ | ||
@@ -715,3 +722,2 @@ // hook created, but not attached (createHook for web component) | ||
let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)) | ||
if(hookName && !this.ownsElement(el)){ return } | ||
let callbacks = this.liveSocket.getHookCallbacks(hookName) | ||
@@ -731,5 +737,8 @@ | ||
destroyHook(hook){ | ||
// __destroyed clears the elementID from the hook, therefore | ||
// we need to get it before calling __destroyed | ||
const hookId = ViewHook.elementID(hook.el) | ||
hook.__destroyed() | ||
hook.__cleanup__() | ||
delete this.viewHooks[ViewHook.elementID(hook.el)] | ||
delete this.viewHooks[hookId] | ||
} | ||
@@ -978,7 +987,8 @@ | ||
elRef.maybeUndo(ref, phxEvent, clonedTree => { | ||
let hook = this.triggerBeforeUpdateHook(el, clonedTree) | ||
DOMPatch.patchWithClonedTree(el, clonedTree, this.liveSocket) | ||
// we need to perform a full patch on unlocked elements | ||
// to perform all the necessary logic (like calling updated for hooks, etc.) | ||
let patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref}) | ||
const phxChildrenAdded = this.performPatch(patch, true) | ||
DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent)) | ||
this.execNewMounted(el) | ||
if(hook){ hook.__updated() } | ||
if(phxChildrenAdded){ this.joinNewChildren() } | ||
}) | ||
@@ -1190,11 +1200,16 @@ } | ||
if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){ | ||
if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){ | ||
let [ref, _els] = refGenerator() | ||
this.undoRefs(ref, phxEvent, [inputEl.form]) | ||
this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => { | ||
callback && callback(resp) | ||
this.triggerAwaitingSubmit(inputEl.form, phxEvent) | ||
this.undoRefs(ref, phxEvent) | ||
}) | ||
} | ||
// the element could be inside a locked parent for other unrelated changes; | ||
// we can only start uploads when the tree is unlocked and the | ||
// necessary data attributes are set in the real DOM | ||
ElementRef.onUnlock(inputEl, () => { | ||
if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){ | ||
let [ref, _els] = refGenerator() | ||
this.undoRefs(ref, phxEvent, [inputEl.form]) | ||
this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => { | ||
callback && callback(resp) | ||
this.triggerAwaitingSubmit(inputEl.form, phxEvent) | ||
this.undoRefs(ref, phxEvent) | ||
}) | ||
} | ||
}) | ||
} else { | ||
@@ -1201,0 +1216,0 @@ callback && callback(resp) |
{ | ||
"name": "phoenix_live_view", | ||
"version": "1.0.2", | ||
"version": "1.0.3", | ||
"description": "The Phoenix LiveView JavaScript client.", | ||
@@ -26,13 +26,31 @@ "license": "MIT", | ||
], | ||
"dependencies": { | ||
"morphdom": "2.7.4" | ||
}, | ||
"devDependencies": { | ||
"@eslint/js": "^9.10.0", | ||
"@playwright/test": "^1.47.1", | ||
"@babel/cli": "7.26.4", | ||
"@babel/core": "7.26.0", | ||
"@babel/preset-env": "7.26.0", | ||
"@eslint/js": "^9.18.0", | ||
"@playwright/test": "^1.49.1", | ||
"@stylistic/eslint-plugin-js": "^2.12.1", | ||
"css.escape": "^1.5.1", | ||
"eslint": "9.18.0", | ||
"eslint-plugin-jest": "28.10.0", | ||
"eslint-plugin-playwright": "^2.1.0", | ||
"monocart-reporter": "^2.8.0" | ||
"globals": "^15.14.0", | ||
"jest": "^29.7.0", | ||
"jest-environment-jsdom": "^29.7.0", | ||
"jest-monocart-coverage": "^1.1.1", | ||
"monocart-reporter": "^2.9.13", | ||
"phoenix": "1.7.18" | ||
}, | ||
"scripts": { | ||
"setup": "mix deps.get && npm install && cd assets && npm install", | ||
"setup": "mix deps.get && npm install", | ||
"e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs", | ||
"e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test", | ||
"js:test": "cd assets && npm install && npm run test", | ||
"js:test": "jest", | ||
"js:test.coverage": "jest --coverage", | ||
"js:test.watch": "jest --watch", | ||
"js:lint": "eslint --fix && cd assets && eslint --fix", | ||
"test": "npm run js:test && npm run e2e:test", | ||
@@ -39,0 +57,0 @@ "cover:merge": "node test/e2e/merge-coverage.mjs", |
@@ -197,14 +197,6 @@ # Phoenix LiveView | ||
```bash | ||
$ cd assets | ||
$ npm install | ||
$ npm run test | ||
# to automatically run tests for files that have been changed | ||
$ npm run test.watch | ||
``` | ||
or simply: | ||
```bash | ||
$ npm run setup | ||
$ npm run js:test | ||
# to automatically run tests for files that have been changed | ||
$ npm run js:test.watch | ||
``` | ||
@@ -211,0 +203,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
1698228
22224
1
16
27
218
+ Addedmorphdom@2.7.4
+ Addedmorphdom@2.7.4(transitive)