behavior Autosave

    on input
        set my isChanged to true
    end

    on blur
        send maybeSave
    end

    on keydown debounced at 10s
        send maybeSave
    end

    on maybeSave
        if my isChanged then 
            set my isChanged to false
            send autosave
        end
    end
end

behavior Autosize 

    on load or input
        set my *height to "auto"
        set my *height to my scrollHeight px
    end
end

behavior blockselect(link)

	init
		set my *cursor to "pointer"
	end

	on click
		if event.target.hasAttribute("href") then
			exit
		end

		if event.target.hasAttribute("hx-get") then
			exit
		end
		
		if event.target.hasAttribute("hx-post") then
			exit
		end
		
		if event.target.hasAttribute("hx-on:click") then
			exit
		end

		if event.target.classList.contains("blockselect-ignore") then
			exit
		end
		
		if window.getSelection().toString() is not "" then
			exit
		end

		if link is null then
			set link to the first <[href],[hx-get],[hx-on\:click]/> in me
		end

		if link is not null then
			click() the link
		end

	end
end
behavior Clipboard

	on click from <.clipboard-copy-button /> in me

		set textElement to the first <.clipboard-text /> in me
		if textElement is null then
			exit
		end

		set the clippedValue to the textElement's innerText
		call navigator.clipboard.writeText(clippedValue)

		tell <.clipboard-copy-button /> in me
			hide yourself
		end

		tell <.clipboard-copy-feedback /> in me
			show yourself
			wait 1.5s
			hide yourself
		end

		tell <.clipboard-copy-button /> in me
			show yourself
		end
	end

end
behavior colorpicker

	init
		set :path to my @data-path	
		set value to my @data-value

		if value is null
			set value to ""
		end

		set pattern to "^#[0-9a-fA-F]{6}$"
		set label to my @data-label

		put `<input id="colorpicker-input-${:path}" type="text" name="${:path}" value="${value}" aria-label="${label}" aria-description="Type a hex color or use the color picker button" style="width:6em" minlength="7" maxlength="7" pattern="${pattern}" script="on focus trigger selectText end on keyup trigger setColor">` into me
		put `&nbsp;` at the end of me
		put `<input id="colorpicker-picker-${:path}" type="color" script="on input trigger pickColor" style="position:absolute; visibility:hidden;">` at the end of me
		put `<button type="button" aria-label="Color picker for ${label}" aria-hidden="true" id="colorpicker-button-${:path}" script="on click trigger togglePicker"><i class="bi bi-palette"></i></button>` at the end of me

		set myInputID to `colorpicker-input-${:path}`
		set myButtonID to `colorpicker-button-${:path}`
		set myPickerID to `colorpicker-picker-${:path}`

		set :input to #{myInputID}
		set :button to #{myButtonID}
		set :picker to #{myPickerID}

		trigger setColor
	end

	on selectText
		wait a tick
		call the :input's focus()
		call the :input's select()
	end

	on togglePicker
		call the :picker's showPicker()
	end

	on pickColor
		set the :input's value to the :picker's value
		trigger setColor
	end

	on setColor 

		if :input.value is not "" then

			if :input.value.charAt(0) is not "\#" then 
				set the :input.value to "\#" + :input.value
			end

			set color to ColorValue(:input.value)

			if color is not "" then 
				set the :input's *backgroundColor to color
				set the :input's *color to textColor(color)
				exit
			end
		
		end

		set the :input's *backgroundColor to ""
		set the :input's *color to ""
	end

end

def ColorValue(value)

	if no value then
		return ""
	end

	make a RegExp from "^\#[0-9a-f]{6}$", "i" called colorPattern

	if not colorPattern.test(value) then
		return ""
	end

	return value
end

def textColor(color)
	set colorNumber to parseInt("0x" + color.substring(1))
	
	set blue to colorNumber mod 256
	set colorNumber to Math.floor(colorNumber / 256)
	set green to colorNumber mod 256
	set red to Math.floor(colorNumber / 256)
	set average to (red + blue + green) / 3

	if average > 127 then 
		return "#000000"
	end

	return "#ffffff"
end
behavior PrettyDate(date)

	on load

		if date == 0 then 
			exit
		end
			
		repeat forever 
			make a Date from (date) called original
			make a Date from (Date.now()) called now
			
			set milisecondCount to (now - original)
			set secondCount to Math.floor(milisecondCount / 1000)
		
			if secondCount < 60 then
				set my innerHTML to "just now"
				wait ((60 * 1000) - milisecondCount) ms 
				continue
			end
			
			set minuteCount to Math.floor(secondCount / 60)

			if minuteCount < 60 then 
				set my innerHTML to minuteCount + "min ago"
				set delay to (60000 - Math.floor(milisecondCount / 60000))
				wait delay ms
				continue
			end

			set hourCount to Math.floor(minuteCount / 60)

			if hourCount < 24 then
				set my innerHTML to hourCount + "h ago"
				exit
			end

			set dayCount to Math.floor(hourCount / 24)

			if dayCount < 4 then
				set my innerHTML to dayCount + "d ago"
				exit
			end

			set my innerHTML to original.toLocaleDateString('en-US', {
				day:'numeric',
				month:'long',
				year:'numeric'
			})
			exit

		end
	end
end
behavior hotkey

	on keydown(key, metaKey, shiftKey, ctrlKey)

		set shortcut to ""

		if window.navigator.userAgent contains "Macintosh" then 
			if metaKey then 
				append "Ctrl+" to shortcut
			end
		else 
			if ctrlKey then
				append "Ctrl+" to shortcut
			end
		end

		if shiftKey then
			append "Shift+" to shortcut
		end

		append key.toUpperCase() to shortcut

		set button to first <[aria-keyshortcuts="${shortcut}"] />

		if button is undefined then
			exit
		end

		halt the event
		send click to button
	end
end
behavior MediaPlayer

	init

		tell <.media-show-when-playing /> in me
			hide yourself
		end

		tell <.media-show-when-paused /> in me
			show yourself
		end

		set :trackNumber to 0
		set :playButton to the first <.media-Play /> in me
		set :pauseButton to the first <.media-Pause /> in me
		set :nextButton to the first <.media-Next /> in me
		set :prevButton to the first <.media-Prev /> in me
		set :loopButton to the first <.media-Loop /> in me
		set :progressBar to the first <.media-Progress /> in me
		set :media to the first <audio,video /> in me

		-- save original values for the name, artist, and image
		set :name to the first <.media-Name /> in me
		set :artist to the first <.media-Artist /> in me
		set :image to the first <.media-Image /> in me

		if the :name is not null then
			set the :name's @data-original to the :name's innerText
		end

		if the :artist is not null then
			set the :artist's @data-original to the :artist's innerText
		end

		if the :image is not null then
			set the :image's @data-original to the :image's @src
		end

		-- MediaSession API
		if navigator.mediaSession is not null then
			navigator.mediaSession.setActionHandler("play", doEvent(me, "Play"))
			navigator.mediaSession.setActionHandler("pause", doEvent(me, "Pause"))
			navigator.mediaSession.setActionHandler("stop", doEvent(me, "Pause"))
			navigator.mediaSession.setActionHandler("previoustrack", doEvent(me, "Prev"))
			navigator.mediaSession.setActionHandler("nexttrack", doEvent(me, "Next"))
			set navigator.mediaSession.playbackState to "paused"
		end
	end

	on click from <.media-Play /> in me
		trigger Play
	end

	on click from <.media-Pause /> in me
		trigger Pause
	end

	on click from <.media-Prev /> in me
		trigger Prev
	end

	on click from <.media-Next /> in me
		trigger Next
	end

	on DragStart from <.media-Progress /> in me
		pause() the :media
	end

	on DragEnd from <.media-Progress /> in me
		set percent to the :progressBar's @value
		trigger Seek(percent:percent)
		play() the :media
	end

	on timeupdate from <audio /> in me or
		timeupdate from <video /> in me

		if :media.duration is undefined then
			exit
		end

		if :progressBar is undefined then
			exit
		end

		set percent to (:media.currentTime / :media.duration) * 100
		send MoveHandle(percent:percent) to :progressBar
	end

	on ended from <audio /> in me or
		ended from <video /> in me
		trigger Next
	end

	on Play(trackNumber)

		set tracks to <[data-track-url] /> in me

		if trackNumber is empty then 
			set trackNumber to :trackNumber
		end

		if trackNumber < 0 then 

			set :trackNumber to 0

			if :loopButton is undefined then
				trigger Pause
				exit
			end

			if not :loopButton.classlist.contains("selected") then
				trigger Pause
				exit
			end

		else if trackNumber >= tracks.length then

			set :trackNumber to 0

			if :loopButton is undefined then
				trigger Pause
				exit
			end

			if not :loopButton.classlist.contains("selected") then
				trigger Pause
				exit
			end

		else 
			set :trackNumber to trackNumber
		end

		set trackElement to tracks[trackNumber]
		set url to the trackElement's @data-track-url

		if url is empty then
			exit
		end

		if url is not equal to :media's @src then
			set :media.src to url
		end

		play() the :media

		take .playing from tracks for trackElement

		tell <.media-show-when-playing /> in me
			show yourself
		end

		tell <.media-show-when-paused /> in me
			hide yourself
		end

		if :name is not undefined then
			set the :name's innerText to (the trackElement's @data-track-name or the :name's @data-original)
		end

		if :artist is not undefined then
			set the :artist's innerText to (the trackElement's @data-track-artist or the :artist's @data-original)
		end

		if :image is not undefined then
			set the :image's @src to (the trackElement's @data-track-image or the :image's @data-original)
		end

		if navigator.mediaSession is not undefined then
			make a MediaMetadata called metadata
			set metadata.title to (the trackElement's @data-track-name or "")
			set metadata.artist to (the trackElement's @data-track-artist or "")
			set metadata.album to (the trackElement's @data-track-album or "")
			set artworkItem to {type:"image/webp", sizes:"512x512", src: ((the trackElement's @data-track-image + ".webp?width=512&height=512") or "")}
			set metadata.artwork to [artworkItem]

			set navigator.mediaSession.metadata to metadata
			set navigator.mediaSession.playbackState to "playing"
		end

	end

	on Seek(percent)
		if :media.duration is undefined then
			exit
		end

		set :media.currentTime to (:media.duration * (percent / 100))
	end

	on Pause

		pause() the :media

		for trackElement in <[data-track-url] /> in me
			remove .playing from trackElement
		end

		tell <.media-show-when-playing /> in me
			hide yourself
		end

		tell <.media-show-when-paused /> in me
			show yourself
		end

		if the :name is not null then
			set the :name's innerText to the :name's @data-original
		end

		if the :artist is not null then
			set the :artist's innerText to the :artist's @data-original
		end

		if the :image is not null then
			set the :image's @src to the :image's @data-original
		end

		if navigator.mediaSession is not undefined then
			set navigator.mediaSession.playbackState to "paused"
		end
	end

	on Prev
		set nextTrackNumber to :trackNumber - 1
		trigger Play(trackNumber:nextTrackNumber)
	end

	on Next
		set nextTrackNumber to :trackNumber + 1
		trigger Play(trackNumber:nextTrackNumber)
	end

	on SetProgress(percent) from <audio /> in me
		
		if :media.duration is not undefined then
			set :media.currentTime to (:media.duration * (percent / 100))
		end
	end

end

js
	function doEvent(node, eventName) {
		return function() {
			var event = new CustomEvent(eventName, {detail: {}});
			node.dispatchEvent(event);
		}
	}
end
behavior Menu(input)

	on load
		trigger selectFirst
	end

	on htmx:afterSwap 
		trigger selectFirst
	end

	on selectFirst
		set selectNext to first <[role=menuitem]/> in me
		if selectNext is not null then
			add [@aria-selected=true] to selectNext
		end
	end

	on focus or mouseover(target)
		set target to the closest <[role=menuitem]/> to target

		if the target is null then
			exit
		end
		
		halt the event

		set items to <[role=menuitem]/> in me
		take [@aria-selected=true] from items for target
	end

	on keydown[key=='ArrowUp']
		halt the event

		set selected to the first <[aria-selected=true]/> in me
		if selected is null then
			set selectNext to last <[role=menuitem]/> in me
		else 
			set menu to the first < .menu /> in me
			set selectNext to the previous <[role=menuitem] /> from selected within menu with wrapping
		end

		if input is null then
			focus the selectNext
		end

		set items to <[role=menuitem]/> in me
		take [@aria-selected=true] from items for selectNext
	end

	on keydown[key=='ArrowDown']
		halt the event

		set selected to the first <[aria-selected=true]/> in me
		if selected is null then
			set selectNext to first <[role=menuitem]/> in me
		else 
			set menu to the first < .menu /> in me
			set selectNext to the next <[role=menuitem]/> from selected within menu with wrapping
		end

		if input is null then
			focus the selectNext
		end
		
		set items to <[role=menuitem]/> in me
		take [@aria-selected=true] from items for selectNext
		
	end

	on keyup[key=='Enter']
		halt the event
		set selected to the first <[aria-selected=true]/> in me
		if selected is not null then
			send click to selected
		end
	end

end
behavior Modal

	init
		add [@role="dialog"]
		set title to the first <h1,h2,h3/> in me

		if (title is not empty) then
			
			if title.id is empty  then 
				set title.id to "modal-title" 
			end

			set the @aria-labelledby to the title's id
		end

		-- Prevent window from scrolling underneath the modal
		-- From: https://stackoverflow.com/questions/44103023/stop-scrolling-on-body-when-modal-is-open-and-allow-scroll-on-modal-divs
		set document.body.style.overflow to "hidden"

		wait a tick then
		add .ready to #modal

		if the first <input,select,radio,checkbox /> in me is not null then
			set firstElement to the first <input,select,radio,checkbox /> in me
		else if #modal-body is not null then
			set firstElement to the first <[tabindex]/> in the #modal-body
		else
			set firstElement to the first <[tabindex]/> in me
		end

		if firstElement is not null then 
			focus the firstElement
		end

		send modalReady
	end

	on closeModal from window	

		if #modal is empty then 
			exit
		end

		-- animate the modal closing
		remove .ready from #modal
		settle

		-- done
		remove #modal

		-- reset window scrolling
		-- From: https://stackoverflow.com/questions/44103023/stop-scrolling-on-body-when-modal-is-open-and-allow-scroll-on-modal-divs
		set document.body.style.overflow to ""
	end
	
	on click (target)
		if the target's id is "modal-underlay" then
			trigger closeModal
		end
	end

	on keydown[key=="Escape"] from window
		if #modal is not empty then 
			trigger closeModal
			halt the event
		end
	end

	on keydown[key=="Tab"]
		set focusedElement to the document's activeElement

		if event.shiftKey then

			if focusedElement is the first <[tabindex]/> in me then
				focus the last <[tabindex]/> in me
				halt the event
			end
			
		else if focusedElement is the last <[tabindex]/> in me then
			focus the first <[tabindex]/> in me
			halt the event
		end
		
	end

	on htmx:beforeSwap from window
		if event.detail.target is the first <aside/> then
			make a RegExp from "^<div " called firstDiv
			set event.detail.serverResponse to event.detail.serverResponse.replace(firstDiv, "<div class='ready' ")
		end
	end
end
behavior multiselect(sort)

	init
		add .multiselect
		add [@role="menu"]

		tell <label/> in me
			add [@tabIndex=0]
			add [@role="menuitem"]
		end

		tell <input/> in me
			add [@role="menuitemcheckbox"]
		end

		-- if available, make the label sortable
		if (sort is true) and (Sortable is not null) then
			tell <div.options/> in me
				make a Sortable from yourself, {animation:150, whisperClass:'multiselect-whisper', onEnd: \ evt -> evt.item.focus()}
			end
		end
	end

	on change	
		set label to the closest <label/> to the target
		if label is not null then
			take .selected for label
		end
	end

	on click from <button[data-sort=up]/> in me		
		set currentLabel to getSelectedLabel(me)

		if currentLabel is null then
			exit
		end

		set container to the currentLabel's parentNode
		set previousLabel to the currentLabel's previousElementSibling

		if previousLabel is null then 
			exit
		end
		
		container.insertBefore(currentLabel, previousLabel)
		focus the target
	end

	on click from <button[data-sort=down]/> in me
		set currentLabel to getSelectedLabel(me)

		if currentLabel is null then
			exit
		end

		set container to the currentLabel's parentNode
		set nextLabel to the currentLabel's nextElementSibling

		if nextLabel is null then 
			exit
		end
		
		container.insertBefore(nextLabel, currentLabel)
		focus the target
	end

	on focus from <label/> in me
		take .selected for target
	end

	on focus from <input/> in me
		focus the closest <label/> to the target
	end

	on keydown[code=="ArrowUp"]
		halt the event
		set currentLabel to getSelectedLabel(me)
		set previousLabel to previous <label/> from currentLabel
		if previousLabel is not null then
			take .selected for previousLabel		
			if currentLabel is document.activeElement then
				focus the previousLabel
			end
		end
	end
	
	on keydown[code=="ArrowDown"]
		halt the event
		set currentLabel to getSelectedLabel(me)
		set nextLabel to next <label/> from currentLabel
		if nextLabel is not null then
			take .selected for nextLabel
			if currentLabel is document.activeElement then
				focus the nextLabel
			end
		end
	end

	on keypress[code=="Space"]

		-- handle "sort" buttons
		if target[@data-sort] is not null then
			exit
		end

		-- otherwise, toggle checkboxes
		set selection to first <label.selected/> in me
		if selection is not null then
			set input to first <input/> in selection
			set input.checked to (input.checked == false)
			focus the selection
			halt the event
		end
	end
end

def getSelectedLabel(container)
	-- try the .selected label
	set result to the first <label.selected/> in container
	if result is not null then
		return result
	end

	-- try the document's active element
	set result to document's activeElement
	if (closest <div.multiselect/> to result) is me then
		take .selected for result
		return result
	end

	-- bail out to the first label in the container
	set result to first <label/> in container
	take .selected for result
	return result
end


behavior PlaceSelect

	init
		set :search to the first <input.PlaceSelectSearch /> in me
		set :formatted to the first <input.PlaceSelectFormatted /> in me
		set :latitude to the first <input.PlaceSelectLatitude /> in me
		set :longitude to the first <input.PlaceSelectLongitude /> in me
		set :menu to the first <[role=menu] /> in me
		set :icon to the first <.PlaceSelectIcon /> in me

		if navigator.geolocation is not null then
			show the :icon
		end
	end

	on click from <.PlaceSelectIcon /> in me
		set position to getCurrentPositionPromise()
		set coords to the position's coords
		log coords
		log coords.longitude
		log coords.latitude
		set the :search's value to "My Current Location"
		set the :latitude's value to coords.latitude
		set the :longitude's value to coords.longitude
		
		hide the :menu
	end

	on input from <input /> in me
		set the :formatted's value to ""
		set the :latitude's value to ""
		set the :longitude's value to ""

		if :search.value is "" then
			hide the :menu
		else
			show the :menu
		end
	end

	on blur from <input /> in me
		wait 500ms
		hide the :menu
	end

	on Select
		set place to the event.target
		set the :search's value to the place's innerText
		set the :formatted's value to the place's innerText
		set the :latitude's value to the place's @data-latitude
		set the :longitude's value to the place's @data-longitude
		hide the :menu
	end

	on keypress[keyCode==Enter] in me
		log "got it??"		
		halt the event
	end

end


js
function getCurrentPositionPromise() {
	return new Promise((resolve, reject) => {
		navigator.geolocation.getCurrentPosition(resolve, reject);
	});
}
end
js
	function singleAnimationFrame() {
		return new Promise(resolve => {
			window.requestAnimationFrame(() => {
				resolve();
			});
		});
	}
end

behavior popUp(position)

	init
		if no position then set the position to "bottom center" end

		add .popUp
		set :button to first <.popUp-button/> in me
		set :content to first <.popUp-content/> in me
		set :button.tabIndex to 0
	end

	on mouseenter or focus
		add .hovered
		trigger showOrClose
	end

	on mouseleave or blur
		remove .hovered
		trigger showOrClose
	end

	on mousedown from .popUp-button in me
		toggle .hovered
		trigger showOrClose
	end

	on touchstart from .popUp-button in me
		toggle .hovered
		trigger showOrClose
	end

	on keydown
		halt the event's bubbling
	end

	on keyup[key=="Escape"]
		remove .hovered
		remove .pinned
		trigger showOrClose
	end

	on showOrClose

		if (my classList contains "hovered") or (my classList contains "pinned") then

			-- Prevent duplicate calls
			if my classList contains "visible" then
				exit
			end

			-- Position Calculations
			set buttonRect to :button's getBoundingClientRect()
			set buttonLeft to :button's offsetLeft
			set buttonWidth to parseInt(:button's *computed-width)
			set contentWidth to parseInt(:content's *computed-width)
			set contentHeight to parseInt(:content's *computed-height)

			-- Horizontal positioning
			if position contains "center" then
				set contentLeft to (buttonLeft + (buttonWidth / 2)) - (contentWidth / 2)

			else if position contains "right" then
				set contentLeft to (buttonLeft + buttonWidth) - contentWidth

			else 
				set contentLeft to the buttonLeft
			end

			-- Vertical positioning
			if position contains "top" then
				set :content's *transform-origin to "bottom"
				set contentTop to the buttonRect's top - contentHeight

				if the :button's offsetParent is not empty then
					set contentTop to contentTop - :button's offsetParent.offsetTop
				end

			else 
				set :content's *transform-origin to "top"
				set contentTop to buttonRect's bottom

				if :button's offsetParent is not empty then
					set contentTop to contentTop - :button's offsetParent.offsetTop
				end
			end

			-- Account for Overflows
			set :content's *top to contentTop px
			set :content's *left to contentLeft px
			get singleAnimationFrame()

			set rect to the :content's getBoundingClientRect()
			set top to the rect's top
			set left to the rect's left

			-- Horizontal Overflows
			if left < 0 then
				set :content's *left to 0px
			else
				set right to left + contentWidth
				set overflowX to (right + 16) - the window's innerWidth
				if overflowX > 0 then
					set :content's *left to (contentLeft - overflowX) px
				end
			end

			-- Vertical Overflows
			if top < 0 then
				set :content's *top to 0px
			else
				set bottom to top + contentHeight
				set overflowY to (bottom + 16) - the window's innerHeight
				if overflowY > 0 then
					set :content's *top to (contentTop - overflowY) px
				end
			end


			-- Display the PopUp
			get singleAnimationFrame()
			add .visible
			-- async transition the :content's opacity to 1 over 125ms
			-- transition the :content's transform to "scaleY(1)" over 125ms
			
		else
			-- Hide the PopUp
			-- async transition the :content's opacity to 0 over 125ms
			-- transition the :content's transform to "scaleY(0)" over 125ms
			remove .visible

			-- Deselect any focused elements
			tell <:focus/> in me 
				you.blur()
			end

		end
		
	end

end
behavior refreshPeers(id)

	init
		set @data-peer to id

	on refreshPeers

		repeat for peer in <[data-peer=`${id}`]/>

			if the peer is not me
				send refresh to peer
			end

		end

	end

end
behavior SaveButton

	init
		if :message exists then exit end

		set :message to my innerHTML
		set my *width to my offsetWidth px
		set my *overflow-x to hidden

		if my.classList.contains("success")
			set my innerHTML to `<i class="bi bi-check"></i> Saved`
			set my *color to my *computed-color
			set my *background-color to my *computed-background-color
			remove .success
			wait 1.5s
	
			transition my *backgroundColor to "" over 1s
			transition my *color to "" over 1s
		end

		set my innerHTML to `
			<span class="button-progress"></span>
			<span class="htmx-request-hide">${:message}</span>
			<span class="htmx-request-show"><span class="spin"><i class="bi bi-arrow-clockwise"></i></span> Saving&hellip;</span>
		`
	end

	on htmx:xhr:loadstart from me.form
		add @disabled to me
	end

	on htmx:xhr:loadend from me.form
		remove @disabled from me
	end

	on htmx:xhr:progress from me.form

		if event.detail.lengthComputable is not true then
			exit
		end

		set progressButton to the first <.button-progress /> in me

		if progressButton is null then
			exit
		end

		set percentComplete to Math.round((event.detail.loaded / event.detail.total) * 100)
		set the progressButton's *width to percentComplete + "%"
	end
end
behavior select(value, options, prefix)

	init

		if options is not null then
			for option in options

				if prefix is not null then 
					set option.value to prefix + option.value
				end
				
				put `<option value="${option.value}">${option.label}</option>` at the end of me
			end
		end

		if value is not ""
			set selectedOption to first <option[value="${value}"]/> in me

			if selectedOption is not null
				set selectedOption.selected to true
			end
		end
	end
end
behavior SelectGroup(children, options, value)

	on load
		wait a tick
		log children
		set :linkedSelect to the first <select[name="${children}"] />
		log linkedSelect

		set :options to JSON.parse(options)
		if :options is null then
			log "cannot parse options JSON"
			log options
			exit
		end

		call selectGroup_setLinkedOptions(:linkedSelect, :options, my value)

		for option in :options
			if option.value == value then
				set :linkedSelect.value to value
				break
			end
		end

	end

	on change
		call selectGroup_setLinkedOptions(:linkedSelect, :options, my value)
	end

end

def selectGroup_setLinkedOptions(linkedSelect, options, value)

	set linkedSelect.innerHTML to ""

	for option in options
		if option.group == value then
			put `<option value="${option.value}">${option.label}</option>` at the end of linkedSelect
		end
	end

end
behavior SelectNav

	-- SelectNav highlights the nav-item whose id is "nav-{id}" and marks it as the
	-- current section.  `.selected` drives the visual state (icon swap + styling);
	-- `aria-current="page"` is the programmatic cue that assistive technology reads
	-- to announce which section the user is in.  Both are mutually exclusive across
	-- the whole nav bar, so we clear every nav-item before marking the chosen one.
	on SelectNav(id)
		set node to document.getElementById('nav-' + id)
		if node is null then exit end
		take .selected from .nav-item for node
		remove [@aria-current] from .nav-item
		add [@aria-current='page'] to node
	end

end

behavior SelectText

    init
        set my *cursor to "pointer"
    end

    on click
        set selection to window.getSelection()
        selection.removeAllRanges()

        set range to document.createRange()
        range.selectNodeContents(me)
        selection.addRange(range) 
    end  
end
behavior showIf(condition)

	init
		set :form to the closest <form/>
		set :expression to parse(condition)
	end

	on load
		trigger recalcShowIf
	end

	on change from closest <form/>
		trigger recalcShowIf
	end

	on recalcShowIf
		set values to :form as Values
		if evaluateExpression(:expression, values) then
			show me
		else
			hide me
		end
	end
end

behavior requiredIf(condition)

	init
		set :form to the closest <form/>
		set :expression to parse(condition)
	end

	on load
		trigger recalcRequiredIf
	end

	on change from closest <form/>
		trigger recalcRequiredIf
	end

	on recalcRequiredIf
		set values to :form as Values
		if evaluateExpression(:expression, values) then
			put [@required] into me
		else
			remove [@required] from me
		end
	end
end


js
function parse(value) {
	if (value == null) {
		return {field: "", operator: "", value: ""}
	}

	var result = {}
	var field = split(value)
	var operator = split(field.tail)
	result["field"] = field.head
	result["operator"] = parseOperator(operator.head)
	result["value"] = operator.tail

	return result
}

function split(value) {
	let i = value.indexOf(" ")
	return {
		head: value.substring(0, i),
		tail: value.substring(i + 1)
	}
}

function parseOperator(value) {

	switch (value) {

	case "eq":
	case "is":
	case "&equals;":
	case "=":
	case "==":
		return "="

	case "ne":
	case "&ne;": 
	case "!=":
		return "!="

	case "gt": 
	case "&gt;": 
	case ">":
		return ">"

	case "lt": 
	case "&lt;":
	case "<":
		return "<"

	case "ge":
	case "&ge;": 
	case ">=":
		return ">="

	case "le":
	case "&le;":
	case "<=":
		return "<="
	}

	return ""
}

function evaluateExpression(exp, object) {
	var value = object[exp.field]

	// Special code for empty values
	if (exp.value == "(null)") {
		exp.value = ""
	}

	switch (exp.operator) {

	case "=":
		return value == exp.value
	case "!=":
		return value != exp.value
	case ">":
		return value > exp.value
	case "<":
		return value < exp.value
	case ">=":
		return value >= exp.value
	case "<=":
		return value <= exp.value
	}

	return false
}

end

behavior showLimit

	init 
		set remainingId to my id + ".remaining"
		set island to the last in my parentNode's childNodes
		set island's *position to "relative"
		append `<span id="${remainingId}" style="position:absolute;right:0px;"></span>` to the island
		set :remainingNode to #{remainingId}
		trigger calcLimit
	end

	on keyup or calcLimit
		set length to my value's length
		set maxlength to my @maxlength
		set remaining to parseInt(maxlength) - length
		set the message to length + "/" + maxlength
		set the :remainingNode's innerHTML to the message
	end

end
behavior ShowMore

	init

		set :button to the first <.ShowMore-button /> in me
		set :content to the first <.ShowMore-content /> in me

		add .blockselect-ignore to :button

		measure the :content's height
		if the height is less than maxHeight
			trigger showMore
			exit
		end

		set :content.style.overflowY to "hidden"
		remove @hidden from the :button
	end

	on click from <.ShowMore-button /> in me
		halt the event
		trigger showMore
	end

	on showMore 
		set :content.style.maxHeight to ""
		remove .ShowMore-content from :button
		remove :button
	end

end
behavior Slider

	init
		if my @value is empty then
			set my @value to 0
		end

		put "<div class='track'><div class='progress'></div><div class='handle'></div></div>" into me

		set :track to the first <.track /> in me
		set :progress to the first <.progress /> in me
		set :handle to the first <.handle /> in me
		set :state to "none"

		set the :track's *cursor to "grab"
		set the :track's *height to "10px"	
		set the :track's *display to "flex"

		set the :progress's *width to my.value + "%"
		set the :progress's *background-color to "rgba(0,0,0,0.3)"
		set the :handle's *width to "10px"
		set the :handle's *backgroundColor to "white"
	end

	on mousedown(clientX)
		set :state to "dragging"
		set the :track's *cursor to "grabbing"

		trigger calcHandle(clientX:clientX)
		trigger DragStart
	end

	on mousemove(clientX) from window
		if :state is not "dragging" then
			exit
		end

		trigger calcHandle(clientX:clientX)
	end

	on mouseup(clientX) from window
		if :state is not "dragging" then
			exit
		end

		trigger calcHandle(clientX:clientX)

		set :state to "none"
		set the :track's *cursor to "grab"
		trigger DragEnd
	end

	on calcHandle(clientX) 
		measure the :track's left, width
		set relativeX to clientX - left
		set percent to (relativeX / width) * 100

		if percent < 0 then
			set percent to 0
		else if percent > 100 then
			set percent to 100
		end
				
		trigger MoveHandle(percent:percent)
	end

	on MoveHandle(percent)
		set the :progress's *width to percent + "%"
		set my @value to percent
	end

end
behavior sortContainer

	on load 
		wait 0.5s
		if Sortable is not null then 
			make a Sortable from me, {animation:150, whisperClass:'draggable-whisper'}
		end
	end
end

behavior TabContainer

	-- init handles the default tab selection.  If a document hash exists 
	-- (and points to one of our tabs) then select it first.  Otherwise,
	-- select the first tab in the list
	init
		set :myTablist to first <[role=tablist] /> in me
		set target to the first <[aria-selected="true"]/> in :myTablist
		
		if target is null then 
			set target to first <[role=tab]/> in :myTablist
		end

		send selectTab to target
	end

	-- select highlighted tab on ENTER (additional key to select tabs)
	-- on keydown[keyCode==13]
	--	send select to first <[role=tab]:focus/>
	--	halt the event

	-- handle mouse clicks directly on tabs
	on mousedown(target)[button==0] from <[role=tablist] [role=tab] />
		send selectTab to target
		halt the event
	end

	on click from <[role=tablist] [role=tab] />
		send selectTab to target
		halt the event

	-- handle touch events for phones and tablets
	on touchstart(target) from <[role=tablist] [role=tab] />
		send selectTab to target
		halt the event
	end

	on selectTab(target)

		set target to the closest <[role=tab] /> to the target

		if :myTablist is not the closest <[role=tablist] /> to the target then
			exit
		end

		if target [@disabled] is not null then 
			exit
		end

		for tab in <[role=tab] /> in me
			if :myTablist is the closest <[role=tablist] /> to tab
				if tab == target
					add [@aria-selected="true"] to tab
				else
					remove [@aria-selected] from tab
				end
			end
		end

		for panel in <[role=tabpanel] />
			if panel.parentNode is me then
				set {hidden: (panel[@id] != target[@aria-controls])} on panel
			end
		end
	end
end


behavior toggle

    -- Resource for making inclusive toggle buttons
    -- https://www.smashingmagazine.com/2017/09/building-inclusive-toggle-buttons/

    init
        add .toggle-container to me
        add [@role=switch] to me
        add [@tabIndex=0] to me

        tell .toggle in me
            remove yourself
        end

        append `<span class="toggle"><span class="marker"></span></span>` to my innerHTML
        append `<input type="hidden" name="${my @name}" value=""/>` to my innerHTML

        if (my [@text] != "") or (my [@true-text] != "") or (my [@false-text] != "") then
            append `<label></label>` to my innerHTML
        end
        
        send recalculate
        wait a tick
        set my *transition to "background-color 0.1s ease-in-out, border-color 0.1s ease-in-out"
	end

    on mousedown or touchstart
        halt the event
        send toggle
	end

    on keypress[code=="Space"]
        halt the event
        send toggle
	end

    on toggle
		if my @value == "true" then
			trigger setFalse
        else 
			trigger setTrue
        end
	end

	on setTrue
		set my @value to "true"
		send recalculate
		send change
	end

	on setFalse
		set my @value to "false"
		send recalculate
		send change
	end

    on recalculate

        set hidden to first <input[type=hidden]/> in me

        if my @value == "true" then
            set hidden's @value to "true"
            set text to my [@true-text]
            set my [@aria-checked]  to "true"
            
        else 
            set hidden's @value to "false"
            set text to my [@false-text]
            set my [@aria-checked]  to "false"
        end

		if my [@text] is not null then
			set text to my [@text]
		end

        set label to the first <label/> in me

        if the label is not null then
            set the label's innerHTML to the text
        end
	end
end
behavior ToggleContainer

	on ToggleShow or click from .ToggleShow in me
		tell the first <.ToggleShow /> in me hide end
		tell the first <.ToggleHide /> in me show end
		tell the first <.ToggleContent /> in me show end
	end

	on ToggleHide or click from .ToggleHide in me
		tell the first <.ToggleShow /> in me show end
		tell the first <.ToggleHide /> in me hide end
		tell the first <.ToggleContent /> in me hide end
	end

end
behavior tooltipContainer

	init
		add .tooltip-container
		add [@hx-target="this"]
		add [@hx-swap="beforeend"]
		add [@hx-trigger="loadTooltip"]
		htmx.process(me)
	end

	on touchstart
		send loadTooltip
	end

	on click
		send loadTooltip
	end

	on mouseenter
		send loadTooltip
	end

	on mouseleave
		send closeTooltip to window
	end

	on touchstart elsewhere
		send closeTooltip to window
	end

	on click elsewhere
		send closeTooltip to window
	end
end

behavior tooltip

	on closeTooltip from window
		if #tooltip is not null then 
			add .closing to #tooltip
			settle
			remove #tooltip
		end
	end
end
behavior DropToUpload

	on dragenter
		halt the event
		add .highlight to me
	end

	on dragover
		halt the event
		add .highlight to me
	end

	on dragleave
		halt the event
		remove .highlight from me
	end

	on drop(dataTransfer)
		halt the event
		remove .highlight from me

		set input to the first <input[type="file"]/> in me
		set the input's files to the dataTransfer's files
		send change to the input
	end

end
behavior validator(url)

	on load
		trigger validate
	end

	on keyup queue last
		trigger validate
	end

	on validate

		if my.value is "" then
			tell <.badge /> in my parentNode
				remove yourself
			end
			exit
		end

		set targetUrl to url
		log targetUrl

		if targetUrl.indexOf("?") == -1 then
			set targetUrl to targetUrl + "?"
		end

		set targetUrl to targetUrl + "&field=" + encodeURIComponent(my.name) + "&value=" + encodeURIComponent(my.value)

		fetch `${targetUrl}` as json

		tell <.badge /> in my parentNode
			remove yourself
		end

		if the result.valid is true then
			me.setCustomValidity("")
			put `<span class="green badge">&check;</span>` at the end of my parentNode
		else 
			me.setCustomValidity(result.message)
			put `<span class="red badge">X</span>` at the end of my parentNode
		end

		me.reportValidity()

	end

end
behavior width_100

	init 
		set my.style.width to my parentElement's *computed-width
	end

	on resize from window
		set my.style.width to my.parentElement's *computed-width
	end
end
