node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
1,133 lines (961 loc) • 43 kB
JavaScript
/** Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
*
* Version: See the class code
*
*/
/** Copyright (c) 2025-2025 Julian Knight (Totally Information)
* https://it.knightnet.org.uk, https://github.com/TotallyInformation
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import TiBaseComponent from './ti-base-component.mjs'
/** Only use a template if you want to isolate the code and CSS using the Shadow DOM */
const template = document.createElement('template')
template.innerHTML = /* html */`
<style>
:host {
display: block; /* default is inline */
contain: content; /* performance boost */
position: fixed; /* Float over all content */
top: var(--uib-control-top, 1.25rem);
right: var(--uib-control-right, 1.25rem);
z-index: var(--uib-control-z-index, 9999); /* Ensure it floats above other content */
max-width: var(--uib-control-max-width, 18.75rem);
}
.control-container {
background: var(--uib-control-bg, hsl(0, 0%, 98%));
border: var(--uib-control-border, 1px solid hsl(0, 0%, 85%));
border-radius: var(--uib-control-border-radius, 0.5rem);
box-shadow: var(--uib-control-shadow, 0 0.25rem 0.75rem hsla(0, 0%, 0%, 0.15));
transition: var(--uib-control-transition, all 0.3s ease);
position: relative;
}
.control-container.dragging {
transition: none;
user-select: none;
z-index: 10000;
}
.emoji-toggle {
cursor: pointer;
padding: var(--uib-control-emoji-padding, 0.5rem 0.75rem);
font-size: var(--uib-control-emoji-size, 1.5rem);
background: transparent;
border: none;
display: block;
width: 100%;
text-align: center;
user-select: none;
transition: transform 0.2s ease;
position: relative;
}
.emoji-toggle.dragging {
cursor: grabbing;
}
.emoji-toggle:hover {
transform: scale(1.1);
}
.emoji-toggle:focus {
outline: 0.125rem solid var(--uib-control-focus-color, hsl(220, 90%, 50%));
outline-offset: 0.125rem;
}
.content-box {
padding: 0;
border-top: var(--uib-control-content-border, 1px solid hsl(0, 0%, 90%));
background: var(--uib-control-content-bg, hsl(0, 0%, 100%));
display: none;
min-width: var(--uib-control-content-min-width, 15.625rem);
}
.content-box.show {
display: block;
animation: fadeIn 0.2s ease-out;
}
/* Tab navigation styles */
.tab-navigation {
display: flex;
border-bottom: 1px solid var(--uib-control-content-border, hsl(0, 0%, 90%));
background: var(--uib-control-bg, hsl(0, 0%, 98%));
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: var(--uib-control-text-color, hsl(0, 0%, 20%));
font-size: var(--uib-control-font-size, 0.9rem);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
}
.tab-button:hover {
background: var(--uib-control-content-bg, hsl(0, 0%, 100%));
}
.tab-button:focus {
outline: 0.125rem solid var(--uib-control-focus-color, hsl(220, 90%, 50%));
outline-offset: -0.125rem;
z-index: 1;
position: relative;
}
.tab-button.active {
background: var(--uib-control-content-bg, hsl(0, 0%, 100%));
border-bottom-color: var(--uib-control-focus-color, hsl(220, 90%, 50%));
color: var(--uib-control-focus-color, hsl(220, 90%, 50%));
}
/* Tab content styles */
.tab-content {
position: relative;
}
.tab-panel {
padding: var(--uib-control-content-padding, 1rem);
display: none;
}
.tab-panel.active {
display: block;
}
.tab-panel:focus {
outline: none;
}
fadeIn {
from { opacity: 0; transform: translateY(-0.625rem); }
to { opacity: 1; transform: translateY(0); }
}
/* Default content styling */
.theme-toggle-section {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--uib-control-content-border, hsl(0, 0%, 90%));
}
.theme-label {
display: inline-block;
font-size: var(--uib-control-font-size, 0.9rem);
font-weight: 500;
color: var(--uib-control-text-color, hsl(0, 0%, 20%));
margin-right: 0.5rem;
}
.theme-buttons-container {
display: inline-flex;
border: 1px solid var(--uib-control-border, hsl(0, 0%, 85%));
border-radius: 0.375rem;
overflow: hidden;
background: var(--uib-control-content-bg, hsl(0, 0%, 100%));
}
.theme-button {
padding: 0.375rem 0.75rem;
border: none;
background: var(--uib-control-content-bg, hsl(0, 0%, 100%));
color: var(--uib-control-text-color, hsl(0, 0%, 20%));
font-size: var(--uib-control-font-size, 0.9rem);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border-right: 1px solid var(--uib-control-border, hsl(0, 0%, 85%));
min-width: 2.5rem;
text-align: center;
}
.theme-button:last-child {
border-right: none;
}
.theme-button:hover {
background: var(--uib-control-bg, hsl(0, 0%, 98%));
}
.theme-button:focus {
outline: 0.125rem solid var(--uib-control-focus-color, hsl(220, 90%, 50%));
outline-offset: -0.125rem;
z-index: 1;
position: relative;
}
.theme-button.active {
background: var(--uib-control-focus-color, hsl(220, 90%, 50%));
color: hsl(0, 0%, 100%);
}
.theme-button.active:hover {
background: hsl(220, 90%, 45%);
}
.content-box p {
margin: 0 0 0.5rem 0;
color: var(--uib-control-text-color, hsl(0, 0%, 20%));
font-size: var(--uib-control-font-size, 0.9rem);
line-height: 1.4;
}
.content-box p:last-child {
margin-bottom: 0;
}
</style>
<div class="control-container">
<button class="emoji-toggle" type="button" aria-expanded="false" aria-label="Toggle control panel (click) or drag to move">
🎛️
</button>
<div class="content-box" role="region" aria-label="Control panel content">
<div class="tab-navigation" role="tablist" aria-label="Content tabs">
<button id="tab-btn-1" class="tab-button active" type="button" role="tab" aria-selected="true" aria-controls="tab1">
Settings
</button>
<button id="tab-btn-2" class="tab-button" type="button" role="tab" aria-selected="false" aria-controls="tab2">
Page Info
</button>
</div>
<div class="tab-content">
<div id="tab1" class="tab-panel active" role="tabpanel" aria-labelledby="tab-btn-1" tabindex="0">
<div class="theme-toggle-section">
<label class="theme-label">Theme:</label>
<div class="theme-buttons-container" role="radiogroup" aria-label="Theme selection">
<button id="theme-auto" class="theme-button active" type="button" data-theme="auto" aria-pressed="true">
Auto
</button>
<button id="theme-light" class="theme-button" type="button" data-theme="light" aria-pressed="false">
Light
</button>
<button id="theme-dark" class="theme-button" type="button" data-theme="dark" aria-pressed="false">
Dark
</button>
</div>
</div>
<slot></slot>
</div>
<div id="tab2" class="tab-panel" role="tabpanel" aria-labelledby="tab-btn-2" tabindex="0">
<div id="viewportSize">Window Width: -- px, Height: -- px</div>
<div id="clientSize">Client Width: -- px, Height: -- px</div>
</div>
</div>
</div>
</div>
`
/** Only use this if using Light DOM but want scoped styles */
// const styles = `
// uib-control {
// /* Scoped to this component */
// }
// `
/**
* @class
* @augments TiBaseComponent
* @description Define a new zero dependency custom web component that displays as a floating control panel.
* By default shows just an emoji toggle button that floats over all page content. When clicked,
* toggles between showing just the emoji and displaying a content box with additional content.
*
* @element uib-control
* @license Apache-2.0
* METHODS FROM BASE: (see TiBaseComponent)
* STANDARD METHODS:
* @function attributeChangedCallback Called when an attribute is added, removed, updated or replaced
* @function connectedCallback Called when the element is added to a document
* @function constructor Construct the component
* @function disconnectedCallback Called when the element is removed from a document
* OTHER METHODS:
* @function _setupToggle Private method to setup toggle functionality
* @function _setupThemeToggle Private method to setup theme toggle functionality
* @function _setupTabs Private method to setup tab functionality
* @function _setupDrag Private method to setup drag functionality
* @function _savePosition Private method to save position to localStorage
* @function _restorePosition Private method to restore position from localStorage
* @function _getCurrentTheme Private method to get current theme setting
* @function _setTheme Private method to set theme mode
* CUSTOM EVENTS:
* "uib-control:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
* "uib-control:ready" - Alias for connected. The instance can handle property & attribute changes
* "uib-control:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
* "uib-control:attribChanged" - When a watched attribute changes. `evt.details.data` contains the details of the change.
* "uib-control:toggle" - When the control panel is toggled. `evt.details.data.expanded` indicates whether panel is expanded.
* "uib-control:drag-end" - When dragging ends. `evt.details.data` contains x and y coordinates.
* "uib-control:position-restored" - When saved position is restored. `evt.details.data` contains position and timestamp.
* "uib-control:theme-changed" - When theme is changed. `evt.details.data.theme` contains the selected theme.
* "uib-control:tab-changed" - When tab is changed. `evt.details.data.activeTab` contains the active tab ID.
* NOTE that listeners can be attached either to the `document` or to the specific element instance.
* Standard watched attributes (common across all my components):
* @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load.
* @property {string} name - Optional. HTML name attribute. Included in output _meta prop.
* Other watched attributes:
* @property {boolean} close-on-outside-click - Optional. If present, clicking outside the component will close the panel.
* @property {string} save-position - Optional. If present, saves the dragged position to localStorage. Value is used as storage key (defaults to 'uib-control-position').
* PROPS FROM BASE: (see TiBaseComponent)
* OTHER STANDARD PROPS:
* @property {string} componentVersion Static. The component version string (date updated). Also has a getter that returns component and base version strings.
* Other props:
* By default, all attributes are also created as properties
NB: properties marked with 💫 are dynamic and have getters/setters.
* CSS Custom Properties (for theming):
* --uib-control-top: Top position (default: 1.25rem)
* --uib-control-right: Right position (default: 1.25rem)
* --uib-control-z-index: Z-index for layering (default: 9999)
* --uib-control-max-width: Maximum width of component (default: 18.75rem)
* --uib-control-bg: Background color (default: hsl(0, 0%, 98%))
* --uib-control-border: Border style (default: 1px solid hsl(0, 0%, 85%))
* --uib-control-border-radius: Border radius (default: 0.5rem)
* --uib-control-shadow: Box shadow (default: 0 0.25rem 0.75rem hsla(0, 0%, 0%, 0.15))
* --uib-control-emoji-size: Size of emoji toggle (default: 1.5rem)
* --uib-control-content-padding: Content padding (default: 1rem)
* @slot Container contents - Content displayed in the expandable panel
* @example
* <uib-control name="myControl">
* <p>Your control panel content here</p>
* </uib-control>
* @example
* <uib-control close-on-outside-click save-position="my-panel-pos">
* <div>
* <h3>Draggable Control Panel</h3>
* <button>Action 1</button>
* <button>Action 2</button>
* </div>
* </uib-control>
* @example
* <!-- Save position with default key -->
* <uib-control save-position>
* <p>This panel remembers its position</p>
* </uib-control>
* See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
*/
class UibControl extends TiBaseComponent {
/** Component version */
static componentVersion = '2025-08-28'
// Unique key for local storage
#uniqueKey
#toggleButton
#dragHandlers = {
mouseMove: null,
mouseUp: null,
touchMove: null,
touchEnd: null,
}
#toggleHandlers = {
click: null,
keydown: null,
outsideClick: null,
touchStart: null,
touchEnd: null,
themeChange: null,
tabClick: null,
tabKey: null,
}
#resizeHandler = null
/** Makes HTML attribute change watched
* @returns {Array<string>} List of all of the html attribs (props) listened to
*/
static get observedAttributes() {
return [
// Standard watched attributes:
'inherit-style', 'name',
// Other watched attributes:
'close-on-outside-click', 'save-position',
]
}
/** NB: Attributes not available here - use connectedCallback to reference */
constructor() {
super()
// Only attach the shadow dom if code and style isolation is needed - comment out if shadow dom not required
if (template && template.content) this._construct(template.content.cloneNode(true))
// Otherwise, if component styles are needed, use the following instead:
// this.prependStylesheet(styles, 0)
}
// #region ---- Internal methods ----
/** Setup the toggle functionality for the emoji button
* @private
*/
_setupToggle() {
const toggleButton = this.#toggleButton = this.shadowRoot?.querySelector('.emoji-toggle')
const contentBox = this.shadowRoot?.querySelector('.content-box')
if (!toggleButton || !contentBox) return
let isExpanded = false
// Create handler functions for cleanup
const clickHandler = () => {
isExpanded = !isExpanded
if (isExpanded) {
contentBox.classList.add('show')
toggleButton.setAttribute('aria-expanded', 'true')
} else {
contentBox.classList.remove('show')
toggleButton.setAttribute('aria-expanded', 'false')
}
// Dispatch custom event for external listeners
this._event('toggle', { expanded: isExpanded, })
}
const keydownHandler = (evt) => {
if (evt instanceof KeyboardEvent && evt.key === 'Escape' && isExpanded) {
isExpanded = false
contentBox.classList.remove('show')
toggleButton.setAttribute('aria-expanded', 'false')
this._event('toggle', { expanded: false, })
}
}
// Add event listeners
toggleButton.addEventListener('click', clickHandler)
toggleButton.addEventListener('keydown', keydownHandler)
// For better mobile support, also handle touch events for toggle
let touchStartTime = 0
let touchStartX = 0
let touchStartY = 0
const touchStartHandler = (evt) => {
if (evt.touches.length === 1) {
touchStartTime = Date.now()
touchStartX = evt.touches[0].clientX
touchStartY = evt.touches[0].clientY
}
}
const touchEndHandler = (evt) => {
if (evt.changedTouches.length === 1) {
const touchEndTime = Date.now()
const touchEndX = evt.changedTouches[0].clientX
const touchEndY = evt.changedTouches[0].clientY
const duration = touchEndTime - touchStartTime
const distance = Math.sqrt(
Math.pow(touchEndX - touchStartX, 2)
+ Math.pow(touchEndY - touchStartY, 2)
)
// If it's a quick tap with minimal movement, treat as click
if (duration < 300 && distance < 10) {
clickHandler()
evt.preventDefault()
}
}
}
// Add touch handlers for better mobile click detection
toggleButton.addEventListener('touchstart', touchStartHandler, { passive: true, })
toggleButton.addEventListener('touchend', touchEndHandler, { passive: false, })
// Store handlers for cleanup
this.#toggleHandlers.click = clickHandler
this.#toggleHandlers.keydown = keydownHandler
this.#toggleHandlers.touchStart = touchStartHandler
this.#toggleHandlers.touchEnd = touchEndHandler
// Optional: Close when clicking outside (if desired)
if (this.hasAttribute('close-on-outside-click')) {
const outsideClickHandler = (evt) => {
if (isExpanded && evt.target instanceof Node && !this.contains(evt.target)) {
isExpanded = false
contentBox.classList.remove('show')
toggleButton.setAttribute('aria-expanded', 'false')
this._event('toggle', { expanded: false, })
}
}
document.addEventListener('click', outsideClickHandler)
this.#toggleHandlers.outsideClick = outsideClickHandler
}
// Setup theme toggle functionality
this._setupThemeToggle()
// Setup tab functionality
this._setupTabs()
}
/** Setup theme toggle functionality
* @private
*/
_setupThemeToggle() {
const themeButtonsContainer = this.shadowRoot?.querySelector('.theme-buttons-container')
const themeButtons = this.shadowRoot?.querySelectorAll('.theme-button')
if (!themeButtonsContainer || !themeButtons?.length) return
// Get current theme and set initial state
const currentTheme = this._getCurrentTheme()
this._updateButtonStates(themeButtons, currentTheme)
// Handle theme button clicks
const themeChangeHandler = (evt) => {
if (!(evt.target instanceof HTMLButtonElement) || !evt.target.classList.contains('theme-button')) return
evt.preventDefault()
const selectedTheme = evt.target.getAttribute('data-theme')
if (!selectedTheme) return
// Update button states and apply theme
this._updateButtonStates(themeButtons, selectedTheme)
this._setTheme(selectedTheme)
// Dispatch custom event for external listeners
this._event('theme-changed', { theme: selectedTheme, })
}
themeButtonsContainer.addEventListener('click', themeChangeHandler)
// Store handler for cleanup
this.#toggleHandlers.themeChange = themeChangeHandler
}
/** Update the theme button states
* @private
* @param {NodeList} buttons - The theme button elements
* @param {string} activeTheme - The theme to set as active
*/
_updateButtonStates(buttons, activeTheme) {
buttons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return
const buttonTheme = button.getAttribute('data-theme')
const isActive = buttonTheme === activeTheme
if (isActive) {
button.classList.add('active')
button.setAttribute('aria-pressed', 'true')
} else {
button.classList.remove('active')
button.setAttribute('aria-pressed', 'false')
}
})
}
/** Setup tab functionality
* @private
*/
_setupTabs() {
const tabNavigation = this.shadowRoot?.querySelector('.tab-navigation')
const tabButtons = this.shadowRoot?.querySelectorAll('.tab-button')
const tabPanels = this.shadowRoot?.querySelectorAll('.tab-panel')
if (!tabNavigation || !tabButtons?.length || !tabPanels?.length) return
// Handle tab button clicks
const tabClickHandler = (evt) => {
if (!(evt.target instanceof HTMLButtonElement) || !evt.target.classList.contains('tab-button')) return
evt.preventDefault()
const clickedButton = evt.target
const targetPanelId = clickedButton.getAttribute('aria-controls')
if (!targetPanelId) return
// Update button states
tabButtons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return
const isActive = button === clickedButton
if (isActive) {
button.classList.add('active')
button.setAttribute('aria-selected', 'true')
} else {
button.classList.remove('active')
button.setAttribute('aria-selected', 'false')
}
})
// Update panel visibility
tabPanels.forEach((panel) => {
if (!(panel instanceof HTMLElement)) return
const isActive = panel.id === targetPanelId
if (isActive) {
panel.classList.add('active')
} else {
panel.classList.remove('active')
}
})
// Dispatch custom event for external listeners
this._event('tab-changed', { activeTab: targetPanelId, })
}
// Handle keyboard navigation
const tabKeyHandler = (evt) => {
if (!(evt.target instanceof HTMLButtonElement) || !evt.target.classList.contains('tab-button')) return
const currentIndex = Array.from(tabButtons).indexOf(evt.target)
let targetIndex = currentIndex
switch (evt.key) {
case 'ArrowLeft':
targetIndex = currentIndex > 0 ? currentIndex - 1 : tabButtons.length - 1
evt.preventDefault()
break
case 'ArrowRight':
targetIndex = currentIndex < tabButtons.length - 1 ? currentIndex + 1 : 0
evt.preventDefault()
break
case 'Home':
targetIndex = 0
evt.preventDefault()
break
case 'End':
targetIndex = tabButtons.length - 1
evt.preventDefault()
break
default:
return
}
const targetButton = tabButtons[targetIndex]
if (targetButton instanceof HTMLButtonElement) {
targetButton.focus()
targetButton.click()
}
}
tabNavigation.addEventListener('click', tabClickHandler)
tabNavigation.addEventListener('keydown', tabKeyHandler)
// Store handlers for cleanup
this.#toggleHandlers.tabClick = tabClickHandler
this.#toggleHandlers.tabKey = tabKeyHandler
}
/** Get the current theme setting
* @private
* @returns {string} Current theme ('light', 'dark', or 'auto')
*/
_getCurrentTheme() {
const htmlElement = document.documentElement
if (htmlElement.classList.contains('light')) {
return 'light'
} else if (htmlElement.classList.contains('dark')) {
return 'dark'
}
// Check localStorage for saved preference
try {
const savedTheme = localStorage.getItem('uib-control-theme')
if (savedTheme && ['light', 'dark', 'auto'].includes(savedTheme)) {
return savedTheme
}
} catch (error) {
console.warn('Failed to read theme preference:', error)
}
return 'auto' // Default to browser preference
}
/** Set the theme mode
* @private
* @param {string} theme - Theme mode ('light', 'dark', or 'auto')
*/
_setTheme(theme) {
const htmlElement = document.documentElement
// Remove existing theme classes
htmlElement.classList.remove('light', 'dark')
// Apply new theme
switch (theme) {
case 'light':
htmlElement.classList.add('light')
break
case 'dark':
htmlElement.classList.add('dark')
break
case 'auto':
// No class = browser preference
break
default:
console.warn('Invalid theme mode:', theme)
return
}
// Save to localStorage
try {
localStorage.setItem('uib-control-theme', theme)
} catch (error) {
console.warn('Failed to save theme preference:', error)
}
}
/** Setup drag functionality for moving the control panel
* @private
*/
_setupDrag() {
const toggleButton = this.shadowRoot?.querySelector('.emoji-toggle')
const container = this.shadowRoot?.querySelector('.control-container')
if (!toggleButton || !container) return
let isDragging = false
let dragStarted = false
let startX = 0
let startY = 0
let initialX = 0
let initialY = 0
const dragThreshold = 5 // pixels to move before drag starts
// Mouse events
const startDrag = (evt) => {
const clientX = evt instanceof MouseEvent ? evt.clientX : evt.touches[0].clientX
const clientY = evt instanceof MouseEvent ? evt.clientY : evt.touches[0].clientY
startX = clientX
startY = clientY
const rect = this.getBoundingClientRect()
initialX = rect.left
initialY = rect.top
isDragging = true
dragStarted = false
evt.preventDefault()
}
toggleButton.addEventListener('mousedown', startDrag)
const mouseMove = (evt) => {
if (!isDragging || !(evt instanceof MouseEvent)) return
const deltaX = evt.clientX - startX
const deltaY = evt.clientY - startY
// Check if we've moved enough to start dragging
if (!dragStarted && (Math.abs(deltaX) > dragThreshold || Math.abs(deltaY) > dragThreshold)) {
dragStarted = true
container.classList.add('dragging')
toggleButton.classList.add('dragging')
}
if (dragStarted) {
const newX = initialX + deltaX
const newY = initialY + deltaY
// Constrain to viewport
const maxX = window.innerWidth - this.offsetWidth
const maxY = window.innerHeight - this.offsetHeight
const constrainedX = Math.max(0, Math.min(newX, maxX))
const constrainedY = Math.max(0, Math.min(newY, maxY))
this.style.left = `${constrainedX}px`
this.style.top = `${constrainedY}px`
this.style.right = 'auto'
this.style.bottom = 'auto'
evt.preventDefault()
}
}
const mouseUp = (evt) => {
if (!isDragging) return
const wasDragStarted = dragStarted
isDragging = false
if (dragStarted) {
dragStarted = false
container.classList.remove('dragging')
toggleButton.classList.remove('dragging')
// Save position if save-position attribute is present
if (this.hasAttribute('save-position')) {
this._savePosition()
}
// Dispatch custom event
this._event('drag-end', {
x: parseInt(this.style.left, 10) || 0,
y: parseInt(this.style.top, 10) || 0,
})
}
// Only prevent click event if we actually dragged
if (wasDragStarted) {
evt.preventDefault()
evt.stopPropagation()
}
}
// Touch events for mobile
toggleButton.addEventListener('touchstart', startDrag)
const touchMove = (evt) => {
if (!isDragging || !(evt instanceof TouchEvent) || evt.touches.length !== 1) return
const touch = evt.touches[0]
const deltaX = touch.clientX - startX
const deltaY = touch.clientY - startY
// Check if we've moved enough to start dragging
if (!dragStarted && (Math.abs(deltaX) > dragThreshold || Math.abs(deltaY) > dragThreshold)) {
dragStarted = true
container.classList.add('dragging')
toggleButton.classList.add('dragging')
}
if (dragStarted) {
const newX = initialX + deltaX
const newY = initialY + deltaY
// Constrain to viewport
const maxX = window.innerWidth - this.offsetWidth
const maxY = window.innerHeight - this.offsetHeight
const constrainedX = Math.max(0, Math.min(newX, maxX))
const constrainedY = Math.max(0, Math.min(newY, maxY))
this.style.left = `${constrainedX}px`
this.style.top = `${constrainedY}px`
this.style.right = 'auto'
this.style.bottom = 'auto'
evt.preventDefault()
}
}
const touchEnd = (evt) => {
if (!isDragging) return
const wasDragStarted = dragStarted
isDragging = false
if (dragStarted) {
dragStarted = false
container.classList.remove('dragging')
toggleButton.classList.remove('dragging')
// Save position if save-position attribute is present
if (this.hasAttribute('save-position')) {
this._savePosition()
}
// Dispatch custom event
this._event('drag-end', {
x: parseInt(this.style.left, 10) || 0,
y: parseInt(this.style.top, 10) || 0,
})
}
// Only prevent click event if we actually dragged
if (wasDragStarted) {
evt.preventDefault()
evt.stopPropagation()
}
}
// Add event listeners and store references for cleanup
document.addEventListener('mousemove', mouseMove)
document.addEventListener('mouseup', mouseUp)
document.addEventListener('touchmove', touchMove)
document.addEventListener('touchend', touchEnd)
document.addEventListener('touchcancel', touchEnd) // Handle touch cancellation same as touch end
// Store references for cleanup in disconnectedCallback
this.#dragHandlers = {
mouseMove,
mouseUp,
touchMove,
touchEnd,
}
}
/** Save the current position to localStorage
* @private
*/
_savePosition() {
const saveKey = this.getAttribute('save-position') || 'uib-control-position'
const position = {
left: this.style.left,
top: this.style.top,
timestamp: Date.now(),
}
this.#uniqueKey = this.id ? `${this.id}-${saveKey}` : saveKey
try {
localStorage.setItem(this.#uniqueKey, JSON.stringify(position))
} catch (error) {
console.warn('Failed to save uib-control position:', error)
}
}
/** Restore saved position from localStorage
* @private
*/
_restorePosition() {
if (!this.hasAttribute('save-position')) return
const saveKey = this.getAttribute('save-position') || 'uib-control-position'
try {
const savedData = localStorage.getItem(this.#uniqueKey)
if (savedData) {
const position = JSON.parse(savedData)
if (position.left && position.top) {
this.style.left = position.left
this.style.top = position.top
this.style.right = 'auto'
this.style.bottom = 'auto'
// Dispatch event to notify position was restored
this._event('position-restored', {
x: parseInt(position.left, 10) || 0,
y: parseInt(position.top, 10) || 0,
timestamp: position.timestamp,
})
}
}
} catch (error) {
console.warn('Failed to restore uib-control position:', error)
}
}
/** Function to update the viewport size display
* @private
*/
_updateViewportSize() {
// const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
// const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
const width = window.innerWidth
const height = window.innerHeight
const viewportEl = this.shadowRoot?.getElementById('viewportSize')
if (viewportEl) {
viewportEl.textContent = `Window Width: ${width}px, Height: ${height}px`
}
const clientEl = this.shadowRoot?.getElementById('clientSize')
if (clientEl) {
clientEl.textContent = `Client Width: ${document.documentElement.clientWidth}px, Height: ${document.documentElement.clientHeight}px`
}
}
// #endregion ---- Internal methods ----
/** Runs when an instance is added to the DOM
* Runs AFTER the initial attributeChangedCallback's
* @private
*/
connectedCallback() {
this._connect() // Keep at start.
// Add toggle functionality
this._setupToggle()
// Add drag functionality
this._setupDrag()
// Restore saved position if save-position attribute is present
this._restorePosition()
// Initialize theme from saved preference
const savedTheme = this._getCurrentTheme()
if (savedTheme !== 'auto') {
this._setTheme(savedTheme)
}
// Initial call to set the size on page load
this._updateViewportSize()
// Store reference to resize handler for cleanup
this.#resizeHandler = this._updateViewportSize.bind(this)
// Update the size whenever the window is resized
window.addEventListener('resize', this.#resizeHandler)
this._ready() // Keep at end. Let everyone know that a new instance of the component has been connected & is ready
}
/** Runs when an instance is removed from the DOM
* @private
*/
disconnectedCallback() {
// Remove toggle event handlers
if (this.#toggleButton && this.#toggleHandlers) {
if (this.#toggleHandlers.click) {
this.#toggleButton.removeEventListener('click', this.#toggleHandlers.click)
}
if (this.#toggleHandlers.keydown) {
this.#toggleButton.removeEventListener('keydown', this.#toggleHandlers.keydown)
}
if (this.#toggleHandlers.touchStart) {
this.#toggleButton.removeEventListener('touchstart', this.#toggleHandlers.touchStart)
}
if (this.#toggleHandlers.touchEnd) {
this.#toggleButton.removeEventListener('touchend', this.#toggleHandlers.touchEnd)
}
if (this.#toggleHandlers.themeChange) {
const themeButtonsContainer = this.shadowRoot?.querySelector('.theme-buttons-container')
if (themeButtonsContainer) {
themeButtonsContainer.removeEventListener('click', this.#toggleHandlers.themeChange)
}
}
if (this.#toggleHandlers.tabClick || this.#toggleHandlers.tabKey) {
const tabNavigation = this.shadowRoot?.querySelector('.tab-navigation')
if (tabNavigation) {
if (this.#toggleHandlers.tabClick) {
tabNavigation.removeEventListener('click', this.#toggleHandlers.tabClick)
}
if (this.#toggleHandlers.tabKey) {
tabNavigation.removeEventListener('keydown', this.#toggleHandlers.tabKey)
}
}
}
// Remove outside click handler from document
if (this.#toggleHandlers.outsideClick) {
document.removeEventListener('click', this.#toggleHandlers.outsideClick)
}
}
// Remove resize event listener
if (this.#resizeHandler) {
window.removeEventListener('resize', this.#resizeHandler)
this.#resizeHandler = null
}
// Remove drag event listeners
if (this.#dragHandlers.mouseMove) {
document.removeEventListener('mousemove', this.#dragHandlers.mouseMove)
}
if (this.#dragHandlers.mouseUp) {
document.removeEventListener('mouseup', this.#dragHandlers.mouseUp)
}
if (this.#dragHandlers.touchMove) {
document.removeEventListener('touchmove', this.#dragHandlers.touchMove)
}
if (this.#dragHandlers.touchEnd) {
document.removeEventListener('touchend', this.#dragHandlers.touchEnd)
document.removeEventListener('touchcancel', this.#dragHandlers.touchEnd) // Also remove touchcancel handler
}
// Clear handler references
this.#dragHandlers = {
mouseMove: null,
mouseUp: null,
touchMove: null,
touchEnd: null,
}
this.#toggleHandlers = {
click: null,
keydown: null,
outsideClick: null,
touchStart: null,
touchEnd: null,
themeChange: null,
tabClick: null,
tabKey: null,
}
// Clear element references
this.#toggleButton = null
this._disconnect() // Keep at end.
}
/** Runs when an observed attribute changes - Note: values are always strings
* NOTE: On initial startup, this is called for each watched attrib set in HTML.
* and BEFORE connectedCallback is called.
* @param {string} attrib Name of watched attribute that has changed
* @param {string} oldVal The previous attribute value
* @param {string} newVal The new attribute value
* @private
*/
attributeChangedCallback(attrib, oldVal, newVal) {
/** Optionally ignore attrib changes until instance is fully connected
* Otherwise this can fire BEFORE everthing is fully connected.
*/
// if (!this.connected) return
// Don't bother if the new value same as old
if ( oldVal === newVal ) return
// Create a property from the value - WARN: Be careful with name clashes
this[attrib] = newVal
// Add other dynamic attribute processing here.
// If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle
// Keep at end. Let everyone know that an attribute has changed for this instance of the component
this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal, })
}
} // ---- end of Class ---- //
// Make the class the default export so it can be used elsewhere
export default UibControl
/** Self register the class to global
* Enables new data lists to be dynamically added via JS
* and lets the static methods be called
*/
window['UibControl'] = UibControl
// Self-register the HTML tag - Done by uibuilder client library otherwise uibuilder fns can't be used
// customElements.define('uib-control', UibControl)