From dd5e5460c0cd1236d6c266c0e1f8f8750873eded Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Mon, 11 Dec 2023 09:48:42 -0500 Subject: [PATCH] Refactory x-model --- packages/alpinejs/src/directives/x-model.js | 189 +++++++++++--------- 1 file changed, 105 insertions(+), 84 deletions(-) diff --git a/packages/alpinejs/src/directives/x-model.js b/packages/alpinejs/src/directives/x-model.js index be14f51ed..3fe19c061 100644 --- a/packages/alpinejs/src/directives/x-model.js +++ b/packages/alpinejs/src/directives/x-model.js @@ -10,90 +10,84 @@ import { isCloning } from '../clone' directive('model', (el, { modifiers, expression }, { effect, cleanup }) => { let scopeTarget = el - if (modifiers.includes('parent')) { - scopeTarget = el.parentNode - } + if (modifiers.includes('parent')) scopeTarget = el.parentNode - let evaluateGet = evaluateLater(scopeTarget, expression) - let evaluateSet + let [ getValue, setValue ] = generateGetAndSet(evaluateLater, scopeTarget, expression) - if (typeof expression === 'string') { - evaluateSet = evaluateLater(scopeTarget, `${expression} = __placeholder`) - } else if (typeof expression === 'function' && typeof expression() === 'string') { - evaluateSet = evaluateLater(scopeTarget, `${expression()} = __placeholder`) - } else { - evaluateSet = () => {} - } + // Allow programmatic overriding of x-model. + el._x_model = { get() { return getValue() }, set(value) { setValue(value) } } - let getResult = () => { - let result + initializeRadioInput(expression, el) - evaluateGet(value => result = value) + let event = determineListenerEvent(el, modifiers) - // The following code prevents an infinite loop when using: - // x-model="$model" by retreiving an x-model higher in the tree... - if (typeof result === 'object' && result !== null && result._x_modelAccessor) { - return result._x_modelAccessor.closest - } + let removeListener = registerInputEventListener(el, event, modifiers, setValue, getValue) - return result - } + modifiers.includes('fill') && initializeFilledInput(el, event,getValue) - let getValue = () => { - let result = getResult() + // Register the listener removal callback on the element, so that + // in addition to the cleanup function, x-modelable may call it. + // Also, make this a keyed object if we decide to reintroduce + // "named modelables" some time in a future Alpine version. + if (! el._x_removeModelListeners) el._x_removeModelListeners = {} + el._x_removeModelListeners['default'] = removeListener - return isGetterSetter(result) ? result.get() : result - } + cleanup(() => el._x_removeModelListeners['default']()) - let setValue = value => { - let result = getResult() + handleFormResets(el, cleanup) - if (isGetterSetter(result)) { - result.set(value) - } else { - evaluateSet(() => {}, { - scope: { '__placeholder': value } - }) - } - } + el._x_forceModelUpdate = (value) => { + // If nested model key is undefined, set the default value to empty string. + if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = '' - if (typeof expression === 'string' && el.type === 'radio') { - // Radio buttons only work properly when they share a name attribute. - // People might assume we take care of that for them, because - // they already set a shared "x-model" attribute. - mutateDom(() => { - if (! el.hasAttribute('name')) el.setAttribute('name', expression) - }) + // @todo: This is nasty + window.fromModel = true + mutateDom(() => bind(el, 'value', value)) + delete window.fromModel } + effect(() => { + // We need to make sure we're always "getting" the value up front, + // so that we don't run into a situation where because of the early + // the reactive value isn't gotten and therefore disables future reactions. + let value = getValue() + + // Don't modify the value of the input if it's focused. + if (modifiers.includes('unintrusive') && document.activeElement.isSameNode(el)) return + + el._x_forceModelUpdate(value) + }) +}) + +function initializeFilledInput(el, event, getValue) { + if ([null, ''].includes(getValue()) + || (el.type === 'checkbox' && Array.isArray(getValue()))) { + el.dispatchEvent(new Event(event, {})); + } +} + +function determineListenerEvent(el, modifiers) { // If the element we are binding to is a select, a radio, or checkbox // we'll listen for the change event instead of the "input" event. - var event = (el.tagName.toLowerCase() === 'select') + + return (el.tagName.toLowerCase() === 'select') || ['checkbox', 'radio'].includes(el.type) || modifiers.includes('lazy') - ? 'change' : 'input' + ? 'change' : 'input' +} +function registerInputEventListener(el, event, modifiers, setValue, getValue) { // We only want to register the event listener when we're not cloning, since the // mutation observer handles initializing the x-model directive already when // the element is inserted into the DOM. Otherwise we register it twice. - let removeListener = isCloning ? () => {} : on(el, event, modifiers, (e) => { + let removeListener = isCloning ? () => { } : on(el, event, modifiers, (e) => { setValue(getInputValue(el, modifiers, e, getValue())) }) - if (modifiers.includes('fill')) - if ([null, ''].includes(getValue()) - || (el.type === 'checkbox' && Array.isArray(getValue()))) { - el.dispatchEvent(new Event(event, {})); - } - // Register the listener removal callback on the element, so that - // in addition to the cleanup function, x-modelable may call it. - // Also, make this a keyed object if we decide to reintroduce - // "named modelables" some time in a future Alpine version. - if (! el._x_removeModelListeners) el._x_removeModelListeners = {} - el._x_removeModelListeners['default'] = removeListener - - cleanup(() => el._x_removeModelListeners['default']()) + return removeListener +} +function handleFormResets(el, cleanup) { // If the input/select/textarea element is linked to a form // we listen for the reset event on the parent form (the event // does not trigger on the single inputs) and update @@ -102,41 +96,68 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => { let removeResetListener = on(el.form, 'reset', [], (e) => { nextTick(() => el._x_model && el._x_model.set(el.value)) }) + cleanup(() => removeResetListener()) } +} - // Allow programmatic overriding of x-model. - el._x_model = { - get() { - return getValue() - }, - set(value) { - setValue(value) - }, +function initializeRadioInput(expression, el) { + if (typeof expression === 'string' && el.type === 'radio') { + // Radio buttons only work properly when they share a name attribute. + // People might assume we take care of that for them, because + // they already set a shared "x-model" attribute. + mutateDom(() => { + if (!el.hasAttribute('name')) el.setAttribute('name', expression) + }) } +} - el._x_forceModelUpdate = (value) => { - // If nested model key is undefined, set the default value to empty string. - if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = '' +function generateGetAndSet(evaluateLater, scopeTarget, expression) { + let evaluateGet = evaluateLater(scopeTarget, expression) + let evaluateSet - // @todo: This is nasty - window.fromModel = true - mutateDom(() => bind(el, 'value', value)) - delete window.fromModel + if (typeof expression === 'string') { + evaluateSet = evaluateLater(scopeTarget, `${expression} = __placeholder`) + } else if (typeof expression === 'function' && typeof expression() === 'string') { + evaluateSet = evaluateLater(scopeTarget, `${expression()} = __placeholder`) + } else { + evaluateSet = () => { } } - effect(() => { - // We need to make sure we're always "getting" the value up front, - // so that we don't run into a situation where because of the early - // the reactive value isn't gotten and therefore disables future reactions. - let value = getValue() + let getResult = () => { + let result - // Don't modify the value of the input if it's focused. - if (modifiers.includes('unintrusive') && document.activeElement.isSameNode(el)) return + evaluateGet(value => result = value) - el._x_forceModelUpdate(value) - }) -}) + // The following code prevents an infinite loop when using: + // x-model="$model" by retreiving an x-model higher in the tree... + if (typeof result === 'object' && result !== null && result._x_modelAccessor) { + return result._x_modelAccessor.closest + } + + return result + } + + let getValue = () => { + let result = getResult() + + return isGetterSetter(result) ? result.get() : result + } + + let setValue = value => { + let result = getResult() + + if (isGetterSetter(result)) { + result.set(value) + } else { + evaluateSet(() => { }, { + scope: { '__placeholder': value } + }) + } + } + + return [ getValue, setValue ] +} function getInputValue(el, modifiers, event, currentValue) { return mutateDom(() => {