@browser.style/gui-panel
Version:
330 lines (278 loc) • 11.7 kB
JavaScript
const styles = await fetch('./index.css').then(r => r.text());
const DOCKED_WIDTH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gui-panel-docked-w')) || 220;
const POPOVER_WIDTH = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gui-panel-popover-w')) || 265;
const MIN_PANEL_HEIGHT = 100; // Minimum height for the panel
const THROTTLE_DELAY = 100; // Milliseconds to throttle resize events
const icon = paths => `<svg part="icon" viewBox="0 0 24 24">${paths.map(d => `<path d="${d}" />`).join('')}</svg>`;
const throttle = (fn, delay) => {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn.apply(this, args);
}
};
};
const ICONS = {
close: ['M18 6l-12 12', 'M6 6l12 12'],
externalend: ['M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6', 'M11 13l9 -9', 'M15 4h5v5'],
externalstart: ['M12 6h6a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6', 'M13 13l-9 -9', 'M9 4h-5v5'],
reset: ['M3.06 13a9 9 0 1 0 .49 -4.087','M3 4.001v5h5'],
scheme: ['M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0', 'M12 3l0 18', 'M12 9l4.65 -4.65', 'M12 14.3l7.37 -7.37', 'M12 19.6l8.85 -8.85'],
sidebarend: ['M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z', 'M15 4l0 16'],
sidebarstart: ['M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z', 'M9 4l0 16']
};
export default class GuiPanel extends HTMLElement {
#root; #parts = {}; #dragState = null; #resizeState = null;
constructor() {
super();
const useShadow = !this.hasAttribute('noshadow');
this.#root = useShadow ? this.attachShadow({mode: 'open'}) : this;
if (useShadow) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(styles);
this.#root.adoptedStyleSheets = [sheet];
} else {
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
}
const dock = this.getAttribute('dock') || '';
const content = useShadow ?
`<slot name="content"></slot>` :
this.querySelector('[slot="content"]')?.innerHTML || '';
this.id ||= `gui-${Math.random().toString(36).slice(2, 7)}`;
const resetButton = `<button part="icon-button reset"${this.hasAttribute('reset') ? '': ' hidden'}><slot name="reset">${icon(ICONS.reset)}</slot></button>`;
this.#root.innerHTML = `
<header part="header ${dock}">
<nav part="icon-group">
<button part="icon-button scheme"${this.hasAttribute('scheme') ? '' : ' hidden'}>
<slot name="scheme">${icon(ICONS.scheme)}</slot>
</button>
${(dock === 'end' || dock === '') ? resetButton : ''}
<button part="icon-button sidebarstart"${dock === 'start' ? '' : ' hidden'}>
<slot name="externalstart">${icon(ICONS.externalstart)}</slot>
<slot name="sidebarstart">${icon(ICONS.sidebarstart)}</slot>
</button>
</nav>
<strong part="title">${this.getAttribute('title') || '⋮⋮ GUI Panel ⋮⋮'}</strong>
<nav part="icon-group">
${dock === 'start' ? resetButton : ''}
<button part="icon-button sidebarend"${dock === 'end' ? '' : ' hidden'}>
<slot name="externalend">${icon(ICONS.externalend)}</slot>
<slot name="sidebarend">${icon(ICONS.sidebarend)}</slot>
</button>
<button part="icon-button close">
<slot name="close">${icon(ICONS.close)}</slot>
</button>
</nav>
</header>
<div part="content">${content}</div>
<div part="resize-block-start"></div>
<div part="resize-block-end"></div>
<div part="resize-inline-start"></div>
<div part="resize-inline-end"></div>`;
['close', 'scheme', 'reset', 'sidebarend', 'sidebarstart', 'title'].forEach(part =>
this.#parts[part] = this.#root.querySelector(`[part~="${part}"]`)
);
this.addDraggable(this.#parts.title, this);
this.#parts.scheme.addEventListener('click', () => this.classList.toggle('cs'));
const toggleSidebar = () => {
if (this.hasAttribute('popover')) {
this.removeAttribute('popover');
this.style.setProperty('--gui-panel-w', `${DOCKED_WIDTH}px`);
} else {
this.setAttribute('popover', this.hasAttribute('dismiss') ? 'auto' : 'manual');
this.offsetHeight; // Force reflow
this.style.setProperty('--gui-panel-w', `${POPOVER_WIDTH}px`); // Reset width for popover mode
this.handlePopoverToggle(true);
}
};
this.#parts.reset.addEventListener('click', () => {
/* TODO! rework this */
const panelHeight = this.style.getPropertyValue('--gui-panel-h');
this.removeAttribute('style');
if (panelHeight) {
this.style.setProperty('--gui-panel-h', panelHeight);
}
});
this.#parts.sidebarend.addEventListener('click', toggleSidebar);
this.#parts.sidebarstart.addEventListener('click', toggleSidebar);
this.#parts.close.addEventListener('click', () => {
if (this.hasAttribute('docked')) {
// For docked elements, toggle popover attribute
if (this.hasAttribute('popover')) {
this.removeAttribute('popover');
this.style.setProperty('--gui-panel-w', `${DOCKED_WIDTH}px`);
} else {
this.setAttribute('popover', this.hasAttribute('dismiss') ? 'auto' : 'manual');
this.offsetHeight; // Force reflow
this.handlePopoverToggle(true);
}
} else {
// For non-docked elements, close as normal
this.handlePopoverToggle(false);
}
});
if (!this.hasAttribute('popover') && !this.hasAttribute('docked'))
this.setAttribute('popover', this.hasAttribute('dismiss') ? 'auto' : 'manual');
if (this.hasAttribute('docked')) {
this.style.setProperty('--gui-panel-w', `${DOCKED_WIDTH}px`);
}
if (this.hasAttribute('open'))
this.handlePopoverToggle(true);
this._constrainFn = () => this.constrainToViewport();
this._resizeObserver = throttle(this._constrainFn, THROTTLE_DELAY);
window.addEventListener('resize', this._resizeObserver);
}
connectedCallback() {
requestAnimationFrame(() => {
this.style.setProperty('--gui-panel-h', `${this.offsetHeight}px`);
this.constrainToViewport();
});
if (this.hasAttribute('resize')) {
const enabledHandlers = new Set(
this.getAttribute('resize').split(/\s+/).map(dir => `resize-${dir}`)
);
['resize-block-start', 'resize-block-end', 'resize-inline-start', 'resize-inline-end']
.filter(part => enabledHandlers.has(part))
.forEach(part => this.addResizeHandler(
this.#root.querySelector(`[part="${part}"]`),
part.includes('inline') ? 'inline' : 'block'
));
}
}
disconnectedCallback() {
if (this._resizeObserver) {
window.removeEventListener('resize', this._resizeObserver);
}
}
constrainToViewport() {
if (!this.isConnected || this.getAttribute('popover') !== 'manual' || !this.matches(':popover-open')) {
return;
}
const rect = this.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let width = parseInt(this.style.getPropertyValue('--gui-panel-w')) || rect.width;
let height = parseInt(this.style.getPropertyValue('--gui-panel-h')) || rect.height;
if (width > viewportWidth) {
this.style.setProperty('--gui-panel-w', `${Math.max(DOCKED_WIDTH, viewportWidth)}px`);
}
if (height > viewportHeight) {
this.style.setProperty('--gui-panel-h', `${Math.max(MIN_PANEL_HEIGHT, viewportHeight)}px`);
}
const hasCustomX = this.style.getPropertyValue('--gui-panel-x') !== '';
const hasCustomY = this.style.getPropertyValue('--gui-panel-y') !== '';
if (hasCustomX) {
const x = parseInt(this.style.getPropertyValue('--gui-panel-x'));
if (x < 0 || x + rect.width > viewportWidth) {
const newX = Math.max(0, Math.min(x, viewportWidth - rect.width));
this.style.setProperty('--gui-panel-x', `${newX}px`);
}
}
if (hasCustomY) {
const y = parseInt(this.style.getPropertyValue('--gui-panel-y'));
if (y < 0 || y + rect.height > viewportHeight) {
const newY = Math.max(0, Math.min(y, viewportHeight - rect.height));
this.style.setProperty('--gui-panel-y', `${newY}px`);
}
}
}
addDraggable(handle, panel, propX = '--gui-panel-x', propY = '--gui-panel-y') {
const move = e => {
if (!this.#dragState) return;
const {clientX, clientY} = e;
const {startX, startY} = this.#dragState;
panel.style.setProperty(propX, `${Math.max(0, Math.min(
panel.offsetLeft - (startX - clientX),
window.innerWidth - panel.offsetWidth
))}px`);
panel.style.setProperty(propY, `${Math.max(0, Math.min(
panel.offsetTop - (startY - clientY),
window.innerHeight - panel.offsetHeight
))}px`);
// Update start position for next move
Object.assign(this.#dragState, {startX: clientX, startY: clientY});
};
handle.addEventListener('pointerdown', e => {
this.#dragState = {startX: e.clientX, startY: e.clientY};
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', move);
const endDrag = () => {
handle.removeEventListener('pointermove', move);
this.#dragState = null;
};
handle.addEventListener('pointerup', endDrag, {once: true});
handle.addEventListener('pointercancel', endDrag, {once: true});
});
handle.addEventListener('touchstart', e => {
if (e.cancelable) { e.preventDefault() }
}, { passive: false });
}
addResizeHandler(handle, type) {
const move = e => {
if (!this.#resizeState) return;
const {clientX, clientY, startX, startY, edge, startSize, startRight, startBottom} = this.#resizeState;
const delta = type === 'inline' ? e.clientX - startX : e.clientY - startY;
const isStart = edge === 'start';
let newSize = isStart ? startSize - delta : startSize + delta;
const minSize = type === 'inline' ? DOCKED_WIDTH : MIN_PANEL_HEIGHT;
const maxSize = type === 'inline' ? window.innerWidth : window.innerHeight;
newSize = Math.max(minSize, Math.min(newSize, maxSize));
if (isStart) {
if (type === 'inline') {
this.style.setProperty('--gui-panel-x', `${startRight - newSize}px`);
} else {
const newY = startBottom - newSize;
this.style.setProperty('--gui-panel-y', `${Math.max(0, newY)}px`);
}
} else if (type === 'block') {
const currentTop = this.getBoundingClientRect().top;
const maxHeight = window.innerHeight - currentTop;
newSize = Math.min(newSize, maxHeight);
}
this.style.setProperty(`--gui-panel-${type === 'inline' ? 'w' : 'h'}`, `${newSize}px`);
};
handle.addEventListener('pointerdown', e => {
const rect = this.getBoundingClientRect();
this.#resizeState = {
edge: handle.getAttribute('part').split('-').pop(),
startX: e.clientX,
startY: e.clientY,
startSize: type === 'inline' ? rect.width : rect.height,
startRight: rect.right,
startBottom: rect.bottom
};
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', move);
const endResize = () => {
handle.removeEventListener('pointermove', move);
this.#resizeState = null;
this.constrainToViewport();
};
handle.addEventListener('pointerup', endResize, {once: true});
handle.addEventListener('pointercancel', endResize, {once: true});
});
handle.addEventListener('touchstart', e => {
if (e.cancelable) { e.preventDefault(); }
}, { passive: false });
}
handlePopoverToggle(force) {
const wasOpen = this.matches(':popover-open');
if (force === true) {
this.showPopover();
} else if (force === false) {
this.hidePopover();
} else {
if (wasOpen) {
this.hidePopover();
} else {
this.showPopover();
}
}
if (!wasOpen && force !== false) {
requestAnimationFrame(() => this.constrainToViewport());
}
}
}
customElements.define('gui-panel', GuiPanel);