components_ui_dropdown_JRDropdown.bs
import "pkg:/source/utils/misc.bs"
sub init()
' Cache node references
m.buttonBackground = m.top.findNode("buttonBackground")
m.buttonBorder = m.top.findNode("buttonBorder")
m.triggerContent = m.top.findNode("triggerContent")
m.triggerIcon = m.top.findNode("triggerIcon")
m.triggerLabel = m.top.findNode("triggerLabel")
m.menuContainer = m.top.findNode("menuContainer")
m.menuBackground = m.top.findNode("menuBackground")
m.menuItems = m.top.findNode("menuItems")
' State
m.isOpen = false
m.focusedItemIndex = 0
' Trigger button: unfocused state (transparent, only text visible — JRTab pattern)
m.buttonBackground.visible = false
m.buttonBorder.visible = false
' Enable render tracking for trigger button sizing
m.top.enableRenderTracking = true
m.top.observeField("renderTracking", "onRenderComplete")
m.top.observeField("focusedChild", "onFocusChanged")
end sub
' ============================================
' TRIGGER BUTTON
' ============================================
sub onTriggerTextChanged()
m.triggerLabel.text = m.top.triggerText
if m.top.renderTracking = "full"
sizeTriggerButton()
end if
end sub
sub onTriggerIconChanged()
uri = m.top.triggerIconUri
if isValidAndNotEmpty(uri)
m.triggerIcon.uri = uri
m.triggerIcon.visible = true
else
m.triggerIcon.visible = false
end if
if m.top.renderTracking = "full"
sizeTriggerButton()
end if
end sub
sub onTriggerIconBlendColorChanged()
m.triggerIcon.blendColor = m.top.triggerIconBlendColor
end sub
sub onRenderComplete()
if m.top.renderTracking <> "full" then return
sizeTriggerButton()
end sub
' Sizes the trigger button background and border to fit the content.
' Uses the same padding-based approach as TextButton.
sub sizeTriggerButton()
if not isValid(m.triggerLabel) then return
' Measure the trigger content bounding rect
contentRect = m.triggerContent.localBoundingRect()
if contentRect.width = 0 then return
padding = m.top.padding
buttonWidth = contentRect.width + (padding * 2)
buttonHeight = contentRect.height + (padding * 2)
' Size background and border
m.buttonBackground.width = buttonWidth
m.buttonBackground.height = buttonHeight
m.buttonBorder.width = buttonWidth
m.buttonBorder.height = buttonHeight
' Center the content within the button
m.triggerContent.translation = [padding, padding]
' Publish computed width so parent can reposition if needed (e.g., right-aligning)
m.top.triggerWidth = int(buttonWidth)
' Position menu below the trigger button
positionMenu(buttonWidth, buttonHeight)
end sub
' Positions the dropdown menu directly below the trigger button
sub positionMenu(buttonWidth as integer, buttonHeight as integer)
menuWidth = m.top.menuWidth
if menuWidth < buttonWidth
menuWidth = buttonWidth
end if
' Align menu left edge with button left edge, expanding rightward
menuY = buttonHeight + 6 ' 6px gap below trigger
m.menuContainer.translation = [0, menuY]
' Size menu background (height set in onItemsChanged after items are created)
m.menuBackground.width = menuWidth
end sub
' ============================================
' MENU ITEMS
' ============================================
' Rebuilds menu item children when the items array changes
sub onItemsChanged()
items = m.top.items
' Clear existing items
m.menuItems.removeChildren(m.menuItems.getChildren(-1, 0))
m.focusedItemIndex = 0
if not isValidAndNotEmpty(items) then return
menuWidth = m.top.menuWidth
' Ensure menu width is at least as wide as trigger button
buttonWidth = m.buttonBackground.width
if menuWidth < buttonWidth
menuWidth = int(buttonWidth)
end if
' Calculate item height from font size + padding.
' fontSizeMedium is the em-height; actual rendered line height is ~70%.
constants = m.global.constants
itemHeight = int(constants.fontSizeMedium * 0.7) + 40 ' 20px padding top + bottom
for each item in items
menuItem = CreateObject("roSGNode", "JRDropdownItem")
menuItem.text = item.title
menuItem.itemId = item.id
menuItem.itemWidth = menuWidth
menuItem.itemHeight = itemHeight
m.menuItems.appendChild(menuItem)
end for
' Size menu panel to fit all items
totalHeight = items.count() * itemHeight
m.menuBackground.height = totalHeight
m.menuBackground.width = menuWidth
' Update selection indicator if selectedItemId is set
if isValidAndNotEmpty(m.top.selectedItemId)
onSelectedItemChanged()
end if
end sub
' Updates the isSelected visual on menu items to match selectedItemId
sub onSelectedItemChanged()
selectedId = m.top.selectedItemId
for i = 0 to m.menuItems.getChildCount() - 1
item = m.menuItems.getChild(i)
item.isSelected = (item.itemId = selectedId)
end for
end sub
' ============================================
' FOCUS MANAGEMENT
' ============================================
' Called when the dropdown gains or loses focus.
' Shows/hides the trigger button border. Menu requires OK press to open.
sub onFocusChanged()
hasFocus = m.top.isInFocusChain()
if hasFocus
' Show trigger button focus visuals (only when menu is closed)
if not m.isOpen
showTriggerFocus()
end if
m.triggerLabel.color = m.global.constants.colorTextPrimary
else
' Hide all focus visuals and close menu
hideTriggerFocus()
m.triggerLabel.color = m.global.constants.colorTextSecondary
closeMenu()
end if
end sub
' Shows focus border/background on the trigger button
sub showTriggerFocus()
m.buttonBorder.blendColor = m.global.constants.colorPrimary
m.buttonBackground.blendColor = m.global.constants.colorBackgroundSecondary
m.buttonBorder.visible = true
m.buttonBackground.visible = true
end sub
' Hides focus border/background on the trigger button
sub hideTriggerFocus()
m.buttonBorder.visible = false
m.buttonBackground.visible = false
end sub
sub openMenu()
if m.isOpen then return
if m.menuItems.getChildCount() = 0 then return
m.isOpen = true
m.focusedItemIndex = 0
m.menuContainer.visible = true
' Hide trigger border — the focused menu item's own border indicates focus
hideTriggerFocus()
m.menuBackground.blendColor = m.global.constants.colorBackgroundSecondary
updateFocusedItemVisuals()
end sub
sub closeMenu()
if not m.isOpen then return
m.isOpen = false
m.menuContainer.visible = false
' Clear focus on all items
for i = 0 to m.menuItems.getChildCount() - 1
m.menuItems.getChild(i).isFocused = false
end for
' Restore trigger focus border if still focused
if m.top.isInFocusChain()
showTriggerFocus()
end if
end sub
' Updates the isFocused visual state on menu item children
sub updateFocusedItemVisuals()
for i = 0 to m.menuItems.getChildCount() - 1
m.menuItems.getChild(i).isFocused = (i = m.focusedItemIndex)
end for
end sub
' ============================================
' KEY HANDLING
' ============================================
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if m.isOpen
return handleMenuKeyEvent(key)
else
return handleClosedKeyEvent(key)
end if
end function
' Key handling when the dropdown menu is open
function handleMenuKeyEvent(key as string) as boolean
itemCount = m.menuItems.getChildCount()
if key = "down"
if m.focusedItemIndex < itemCount - 1
' Move focus down within menu
m.focusedItemIndex++
updateFocusedItemVisuals()
else
' Past last item — close and return focus to content
closeMenu()
m.top.requestFocusReturn = true
end if
return true
else if key = "up"
if m.focusedItemIndex > 0
' Move focus up within menu
m.focusedItemIndex--
updateFocusedItemVisuals()
else
' Past first item — close menu and return to trigger button
closeMenu()
end if
return true
else if key = "OK"
' Select the focused item
selectedItem = m.menuItems.getChild(m.focusedItemIndex)
if isValid(selectedItem)
m.top.selectedAction = selectedItem.itemId
end if
closeMenu()
return true
else if key = "left"
closeMenu()
m.top.requestFocusExit = "left"
return true
else if key = "right"
closeMenu()
m.top.requestFocusExit = "right"
return true
else if key = "back"
closeMenu()
return true
end if
return false
end function
' Key handling when the dropdown menu is closed
function handleClosedKeyEvent(key as string) as boolean
if key = "OK"
openMenu()
return true
else if key = "down"
m.top.requestFocusReturn = true
return true
else if key = "left"
m.top.requestFocusExit = "left"
return true
else if key = "right"
m.top.requestFocusExit = "right"
return true
end if
' BACK: don't consume — let it bubble to JRScene for normal back navigation
return false
end function
' ============================================
' TEARDOWN
' ============================================
sub onDestroy()
m.top.unobserveField("focusedChild")
m.top.unobserveField("renderTracking")
' Clear menu items
m.menuItems.removeChildren(m.menuItems.getChildren(-1, 0))
m.buttonBackground = invalid
m.buttonBorder = invalid
m.triggerContent = invalid
m.triggerIcon = invalid
m.triggerLabel = invalid
m.menuContainer = invalid
m.menuBackground = invalid
m.menuItems = invalid
end sub