Skip to content

Commit

Permalink
Refactory x-model
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Dec 11, 2023
1 parent 95232e4 commit dd5e546
Showing 1 changed file with 105 additions and 84 deletions.
189 changes: 105 additions & 84 deletions packages/alpinejs/src/directives/x-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand Down

0 comments on commit dd5e546

Please sign in to comment.