Files
gen1recomp/libs/flexlove/modules/Select.lua
T
2026-08-03 11:50:49 -04:00

720 lines
22 KiB
Lua

---@class Select
local Select = {}
---Initialize Select module with required dependencies
---@param deps table
function Select.init(deps)
Select._ErrorHandler = deps.ErrorHandler
Select._Context = deps.Context
Select._StateManager = deps.StateManager
Select._utils = deps.utils
Select._Element = deps.Element
end
---Initialize selectParent state on an element
---@param element Element
---@param selectParentConfig table
function Select.initSelectParent(element, selectParentConfig)
element._selectState = {
value = selectParentConfig.value,
open = selectParentConfig.open or false,
placeholder = selectParentConfig.placeholder,
selectFrame = nil,
selectAnchor = nil,
onChange = selectParentConfig.onChange,
options = {},
optionLookup = {},
expectedFrameParent = nil,
frameAdopted = false,
}
-- Restore select state from StateManager. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Select._StateManager.getState(element._stateId)
if state and state._selectOpen ~= nil then
element._selectState.open = state._selectOpen
end
if state and state._selectValue ~= nil then
element._selectState.value = state._selectValue
if element.selectParent then
element.selectParent.value = state._selectValue
end
end
if state and state._selectSelectedLabel ~= nil then
element._selectState.selectedLabel = state._selectSelectedLabel
end
end
end
---Initialize selectOption on an element
---@param element Element
---@param selectOptionConfig table
function Select.initSelectOption(element, selectOptionConfig)
element.selectOption = {
value = selectOptionConfig.value,
label = selectOptionConfig.label or element.text,
disabled = selectOptionConfig.disabled or false,
}
end
---@param selectParent Element
function Select.rebuildOptionLookup(selectParent)
if not selectParent or not selectParent._selectState then
return
end
selectParent._selectState.optionLookup = {}
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement and optionElement.selectOption then
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
end
end
end
---@param selectParent Element
function Select.syncOptionStates(selectParent)
if not selectParent or not selectParent._selectState then
return
end
local selectedOption = nil
local selectedLabel = selectParent._selectState.selectedLabel
for _, optionElement in ipairs(selectParent._selectState.options) do
local isSelected = optionElement.selectOption
and optionElement.selectOption.value == selectParent._selectState.value
optionElement._selectSelected = isSelected
optionElement.ariaChecked = isSelected
if isSelected then
selectedOption = optionElement
selectedLabel = optionElement.selectOption.label or optionElement.text
end
end
selectParent._selectState.selectedOption = selectedOption
selectParent._selectState.selectedLabel = selectedLabel
end
---@param element Element
function Select.resetOptions(element)
if not element._selectState then
return
end
element._selectState.options = {}
element._selectState.optionLookup = {}
element._selectState.selectedOption = nil
end
---@param frame any
---@return boolean
function Select.isValidSelectFrame(frame)
local Element = Select._Element
return type(frame) == "table" and getmetatable(frame) == Element
end
---@param element Element
---@param code string
---@param details table?
function Select.warnSelectFrame(element, code, details)
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
end
---@param element Element
---@param frame Element
function Select.trackManagedFrame(element, frame)
element._selectState.selectFrame = frame
local expectedParent = element._selectState.selectAnchor or element
element._selectState.expectedFrameParent = expectedParent
element._selectState.frameAdopted = frame.parent == expectedParent
if frame._managedSelectBaseOpacity == nil then
frame._managedSelectBaseOpacity = frame.opacity
end
if frame._managedSelectBaseVisibility == nil then
frame._managedSelectBaseVisibility = frame.visibility or "visible"
end
if frame._managedSelectBaseDisabled == nil then
frame._managedSelectBaseDisabled = frame.disabled or false
end
frame._managedSelectOwner = element
frame._managedSelectFrame = true
end
---@param element Element
---@return Element
function Select.getOrCreateManagedAnchor(element)
if element._selectState.selectAnchor then
return element._selectState.selectAnchor
end
local Element = Select._Element
local anchor = Element.new({
id = string.format("%s__select_anchor", element.id or "select"),
parent = element,
positioning = Select._utils.enums.Positioning.ABSOLUTE,
left = 0,
top = element:getBorderBoxHeight(),
width = element:getBorderBoxWidth(),
opacity = 1,
visibility = "hidden",
disabled = true,
})
anchor._managedSelectAnchor = true
anchor._managedSelectOwner = element
element._selectState.selectAnchor = anchor
return anchor
end
---@param element Element
---@param frame Element
function Select.applyManagedFrameLayout(element, frame)
local anchor = Select.getOrCreateManagedAnchor(element)
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
frame._explicitlyAbsolute = false
frame.left = nil
frame.top = nil
frame.right = nil
frame.bottom = nil
if frame.parent ~= anchor then
frame:setParent(anchor)
end
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
element._selectState.expectedFrameParent = anchor
element._selectState.frameAdopted = frame.parent == anchor
end
---@param element Element
---@param frame Element
function Select.adoptSelectFrame(element, frame)
if not element._selectState then
return
end
if not Select.isValidSelectFrame(frame) then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
got = type(frame),
})
return
end
if frame == element then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
reason = "select cannot use itself as its managed frame",
})
return
end
local anchor = Select.getOrCreateManagedAnchor(element)
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
Select.warnSelectFrame(element, "ELEM_008", {
element = element.id,
frame = frame.id,
parent = frame.parent.id,
})
end
Select.trackManagedFrame(element, frame)
Select.applyManagedFrameLayout(element, frame)
Select.syncManagedFrameVisibility(element)
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
-- encapsulates the mode check (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
anchor:layoutChildren()
element:layoutChildren()
end
local pendingOptions = {}
for _, child in ipairs(element.children) do
if child ~= frame and child.selectOption then
table.insert(pendingOptions, child)
end
end
for _, option in ipairs(pendingOptions) do
Select.attachOptionToManagedFrame(option)
end
end
---@param element Element
function Select.ensureFrameState(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if anchor then
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
if frame.parent == anchor then
anchor:layoutChildren()
end
elseif frame.parent == element then
Select.applyManagedFrameLayout(element, frame)
end
local expectedParent = anchor or element._selectState.expectedFrameParent
if frame.parent ~= expectedParent then
Select.warnSelectFrame(element, "ELEM_009", {
element = element.id,
frame = frame.id,
expectedParent = expectedParent and expectedParent.id or nil,
actualParent = frame.parent and frame.parent.id or nil,
})
element._selectState.expectedFrameParent = frame.parent
element._selectState.frameAdopted = frame.parent == expectedParent
end
end
---@param element Element
function Select.syncManagedFrameVisibility(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
local isOpen = element._selectState.open == true
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
frame.opacity = frame._managedSelectBaseOpacity or 1
if isOpen then
frame.disabled = frame._managedSelectBaseDisabled == true
else
frame.disabled = true
end
if anchor then
anchor.visibility = isOpen and "visible" or "hidden"
anchor.opacity = 1
anchor.disabled = not isOpen
end
end
---@param element Element
---@return Element?
function Select.findOwningSelectParent(element)
if element._selectParentHint and element._selectParentHint._selectState then
return element._selectParentHint
end
local current = element.parent
while current do
if current._selectState then
return current
end
current = current.parent
end
return nil
end
---@param element Element
function Select.registerWithSelectParent(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent then
return
end
element._selectParentElement = selectParent
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
return
end
end
table.insert(selectParent._selectState.options, element)
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
end
---@param element Element
function Select.attachOptionToManagedFrame(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
return
end
local selectFrame = selectParent._selectState.selectFrame
if element.parent ~= selectFrame then
element._selectParentHint = selectParent
if
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
and element._managedSelectOptionUsesFrameLayout == nil
then
element._managedSelectOptionUsesFrameLayout = true
element.positioning = Select._utils.enums.Positioning.RELATIVE
element._originalPositioning = nil
element._explicitlyAbsolute = false
element.left = nil
element.top = nil
element.right = nil
element.bottom = nil
end
element:setParent(selectFrame)
-- Ensure frame geometry eagerly only in retained mode; deferred to the
-- per-frame update in immediate mode (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
Select.ensureFrameState(selectParent)
end
end
end
---@param element Element
function Select.unregisterFromSelectParent(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
element._selectParentElement = nil
return
end
local selectParent = element._selectParentElement
for index, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
table.remove(selectParent._selectState.options, index)
break
end
end
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
element._selectParentElement = nil
end
---@param element Element
function Select.saveStateToStateManager(element)
if not element._selectState then
return
end
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
Select._StateManager.updateState(element._stateId, {
_selectOpen = element._selectState.open,
_selectValue = element._selectState.value,
_selectSelectedLabel = element._selectState.selectedLabel,
})
end
end
---@param element Element
function Select.openSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = true
element.ariaExpanded = true
if element.selectParent then
element.selectParent.open = true
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.closeSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = false
element.ariaExpanded = false
if element.selectParent then
element.selectParent.open = false
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.toggleSelect(element)
if not element._selectState then
return
end
if element.disabled then
return
end
if element._selectState.open then
Select.closeSelect(element)
else
Select.openSelect(element)
end
if element.onEvent then
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
end
end
---@param element Element
---@return boolean
function Select.isSelectOpen(element)
return element._selectState ~= nil and element._selectState.open == true
end
---@param element Element
---@return any
function Select.getSelectValue(element)
if not element._selectState then
return nil
end
return element._selectState.value
end
---@param element Element
---@return string?
function Select.getSelectLabel(element)
if not element._selectState then
return nil
end
local selectedOption = element._selectState.selectedOption
or element._selectState.optionLookup[element._selectState.value]
if selectedOption and selectedOption.selectOption then
return selectedOption.selectOption.label or selectedOption.text
end
return element._selectState.selectedLabel or element._selectState.placeholder
end
---@param element Element
---@return boolean
function Select.isSelectedOption(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
return false
end
return element._selectParentElement._selectState.value == element.selectOption.value
end
---@param element Element
---@param value any
---@param optionElement Element?
function Select.setSelectValue(element, value, optionElement)
if not element._selectState then
return
end
if element.disabled then
return
end
local didChange = element._selectState.value ~= value
element._selectState.value = value
if element.selectParent then
element.selectParent.value = value
end
if optionElement and optionElement.selectOption then
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
end
Select.syncOptionStates(element)
Select.closeSelect(element)
Select.saveStateToStateManager(element)
if element.onEvent then
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
end
if didChange and element._selectState.onChange then
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
end
end
---@param element Element
function Select.handleRelease(element)
if element.disabled then
return
end
if element.selectOption then
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
if not selectParent then
return
end
if element.selectOption.disabled then
Select.closeSelect(selectParent)
return
end
Select.setSelectValue(selectParent, element.selectOption.value, element)
return
end
if element._selectState then
Select.toggleSelect(element)
end
end
---Save select state for state persistence (called from Element:saveState)
---@param element Element
---@return table?
function Select.saveState(element)
if not element._selectState then
return nil
end
return {
value = element._selectState.value,
open = element._selectState.open,
selectedLabel = element._selectState.selectedLabel,
}
end
---Restore select state (called from Element:restoreState)
---@param element Element
---@param state table
function Select.restoreState(element, state)
if not element._selectState or not state then
return
end
element._selectState.value = state.value
element._selectState.open = state.open or false
element._selectState.selectedLabel = state.selectedLabel
if element.selectParent then
element.selectParent.value = state.value
element.selectParent.open = state.open or false
end
element.ariaExpanded = element._selectState.open
Select.syncOptionStates(element)
end
---Clean up select-related resources (called from Element:destroy)
---@param element Element
function Select.cleanupDestroy(element)
if element._selectState then
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if frame then
frame._managedSelectOwner = nil
frame._managedSelectFrame = nil
frame._managedSelectBaseOpacity = nil
frame._managedSelectBaseVisibility = nil
frame._managedSelectBaseDisabled = nil
end
if anchor then
anchor._managedSelectOwner = nil
anchor._managedSelectAnchor = nil
end
element._selectState = nil
end
if element._managedSelectFrame and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectFrame = nil
element._managedSelectOwner._selectState.expectedFrameParent = nil
element._managedSelectOwner._selectState.frameAdopted = false
end
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
if element._managedSelectAnchor and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectAnchor = nil
end
element._managedSelectOwner = nil
element._managedSelectAnchor = nil
end
if element.selectParent then
element.selectParent.onChange = nil
end
end
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
---@param element Element The select parent whose child was removed.
---@param child Element The removed child.
function Select.handleChildRemoved(element, child)
if not element._selectState then
return
end
if element._selectState.selectFrame == child then
element._selectState.selectFrame = nil
element._selectState.expectedFrameParent = nil
element._selectState.frameAdopted = false
end
if element._selectState.selectAnchor == child then
element._selectState.selectAnchor = nil
end
end
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
---@param element Element The managed-select frame (the dropdown container).
---@param child Element The flex child being measured.
---@param childBorderBoxWidth number Current computed border-box width of `child`.
---@return number Possibly-adjusted border-box width.
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
if
element._managedSelectFrame
and element.autosizing
and element.autosizing.width
and child.units
and child.units.width
and child.units.width.unit == "%"
then
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
end
return childBorderBoxWidth
end
return Select