import {type CSSResultGroup, html, unsafeCSS} from 'lit';
import {eventOptions, property} from 'lit/decorators.js';
import {on} from "../../utilities/on";
import {Store} from "../../internal/storage";
import ZincElement from '../../internal/zinc-element';
import styles from './split-pane.scss';
import { PropertyValues } from "@lit/reactive-element";
type NavigationItem = {
caption: string;
active: boolean;
select: () => void;
};
const MERGED_NAVIGATION_BREAKPOINT_PX = 768;
/**
* @summary Short summary of the component's intended use.
* @documentation https://zinc.style/components/split-pane
* @status experimental
* @since 1.0
*
* @dependency zn-example
*
* @event zn-event-name - Emitted as an example.
*
* @slot - The default slot.
* @slot example - An example slot.
*
* @csspart base - The component's base wrapper.
*
* @cssproperty --example - An example CSS custom property.
*/
export default class ZnSplitPane extends ZincElement {
static styles: CSSResultGroup = unsafeCSS(styles);
storage: Storage;
mouseMoveHandler: null | EventListener = null;
mouseUpHandler: null | EventListener = null;
private currentPixelSize: number = 0;
private currentPercentSize: number = 0;
private currentContainerSize: number = 0;
private focusChangeHandler = () => this.requestUpdate();
private primaryFull: string;
private resizeObserver: ResizeObserver | null = null;
private parentIsNarrow = false;
@property({attribute: 'pixels', type: Boolean, reflect: true}) calculatePixels = false;
@property({attribute: 'secondary', type: Boolean, reflect: true}) preferSecondarySize = false;
@property({attribute: 'min-size', type: Number, reflect: true}) minimumPaneSize = 10;
@property({attribute: 'min-secondary-size', type: Number, reflect: true}) minimumSecondaryPaneSize: number;
@property({attribute: 'max-size', type: Number, reflect: true}) maximumPaneSize: number;
@property({attribute: 'initial-size', type: Number, reflect: true}) initialSize = 50;
@property({attribute: 'store-key', reflect: true}) storeKey: string = "";
@property({attribute: 'bordered', type: Boolean, reflect: true}) border = false;
@property({attribute: 'vertical', type: Boolean, reflect: true}) vertical = false;
@property({attribute: 'primary-caption', reflect: true}) primaryCaption = 'Primary';
@property({attribute: 'secondary-caption', reflect: true}) secondaryCaption = 'Secondary';
@property({attribute: 'focus-pane', type: Number, reflect: true}) _focusPane = 0;
@property({attribute: 'padded', type: Boolean, reflect: true}) padded = false;
@property({attribute: 'padded-right', type: Boolean, reflect: true}) paddedRight = false;
@property({type: Boolean, reflect: true}) gap = false;
@property({reflect: true}) hide: 'primary' | 'secondary' | '' = '';
@property({attribute: 'merged-navigation', type: Boolean, reflect: true}) mergedNavigation = false;
// session storage if not local
@property({attribute: 'local-storage', type: Boolean, reflect: true}) localStorage: boolean;
@property({attribute: 'store-ttl', type: Number, reflect: true}) storeTtl = 0;
protected _store: Store;
connectedCallback() {
super.connectedCallback();
this._store = new Store(this.localStorage ? window.localStorage : window.sessionStorage, "znsp:", this.storeTtl);
this.primaryFull = this.calculatePixels ? this.initialSize + 'px' : this.initialSize + '%';
on(this, 'click', '[split-pane-focus]', (e: Event & { selectedTarget: EventTarget }) => {
e.preventDefault();
e.stopPropagation();
this._setFocusPane(parseInt((e.selectedTarget as HTMLElement).getAttribute('split-pane-focus')!));
});
this.addEventListener('zn-split-pane-focus-change', this.focusChangeHandler);
this.resizeObserver = new ResizeObserver(() => this.refreshNarrowState());
this.resizeObserver.observe(this);
}
disconnectedCallback() {
this.removeEventListener('zn-split-pane-focus-change', this.focusChangeHandler);
this.resizeObserver?.disconnect();
this.resizeObserver = null;
super.disconnectedCallback();
}
private refreshNarrowState() {
const nowNarrow = this.getBoundingClientRect().width < MERGED_NAVIGATION_BREAKPOINT_PX;
if (nowNarrow !== this.parentIsNarrow) {
this.parentIsNarrow = nowNarrow;
this.updateNestedNavigationMerging();
this.requestUpdate();
}
}
firstUpdated(changedProperties: PropertyValues) {
setTimeout(this.applyStoredSize.bind(this), 100);
this.refreshNarrowState();
super.firstUpdated(changedProperties);
}
applyStoredSize() {
this.currentContainerSize = (this.vertical ? this.getBoundingClientRect().height : this.getBoundingClientRect().width);
let applyPixels = this.preferSecondarySize ? this.currentContainerSize - this.initialSize : this.initialSize;
let applyPercent = this.preferSecondarySize ? 100 - this.initialSize : this.initialSize;
const storedValue = this._store.get(this.storeKey);
if (storedValue !== null) {
const parts = storedValue.split(",");
if (parts.length >= 3) {
applyPixels = parseInt(parts[0]);
applyPercent = parseInt(parts[1]);
const storedBasis = parseInt(parts[2]);
if (this.preferSecondarySize && this.calculatePixels) {
applyPixels *= (this.currentContainerSize / storedBasis);
}
}
}
this.setSize(this.calculatePixels ? applyPixels : (this.currentContainerSize / 100) * applyPercent);
}
@eventOptions({passive: true})
resize(e: any) {
if (this.hide === 'primary' || this.hide === 'secondary') {
return;
}
if (this.mouseUpHandler !== null) {
this.mouseUpHandler(e);
}
this.classList.add('resizing');
this.currentContainerSize = this.vertical ? this.getBoundingClientRect().height : this.getBoundingClientRect().width;
const pageOffset = this.vertical ? this.getBoundingClientRect().top : this.getBoundingClientRect().left;
this.mouseMoveHandler = function (e: any) {
let offset = (this.vertical ? e.y : e.x);
//'touches' in e fixes Safari
if ('touches' in e && e instanceof TouchEvent) {
offset = (this.vertical ? e.touches[0].clientY : e.touches[0].clientX);
}
this.setSize(offset - pageOffset);
}.bind(this);
this.mouseUpHandler = function () {
this._store.set(this.storeKey, Math.round(this.currentPixelSize) + "," + Math.round(this.currentPercentSize) + "," + this.currentContainerSize);
this.classList.remove('resizing');
window.removeEventListener('touchmove', this.mouseMoveHandler);
window.removeEventListener('mousemove', this.mouseMoveHandler);
window.removeEventListener('touchend', this.mouseUpHandler);
window.removeEventListener('mouseup', this.mouseUpHandler);
}.bind(this);
window.addEventListener('touchmove', this.mouseMoveHandler);
window.addEventListener('touchend', this.mouseUpHandler);
window.addEventListener('mousemove', this.mouseMoveHandler);
window.addEventListener('mouseup', this.mouseUpHandler);
}
setSize(primaryPanelPixels: number) {
const hasMinimumSecondaryPaneSize = Number.isFinite(this.minimumSecondaryPaneSize);
const maximumPrimaryPanelPixels = hasMinimumSecondaryPaneSize
? this.currentContainerSize - (this.calculatePixels
? this.minimumSecondaryPaneSize
: (this.currentContainerSize / 100) * this.minimumSecondaryPaneSize)
: undefined;
let pixelSize = maximumPrimaryPanelPixels === undefined
? primaryPanelPixels
: Math.min(primaryPanelPixels, maximumPrimaryPanelPixels);
let percentSize = (pixelSize / this.currentContainerSize) * 100;
if (this.calculatePixels) {
const minimumPrimaryPanelPixels = maximumPrimaryPanelPixels === undefined
? this.minimumPaneSize
: Math.min(this.minimumPaneSize, maximumPrimaryPanelPixels);
if (this.maximumPaneSize || maximumPrimaryPanelPixels !== undefined) {
pixelSize = Math.max(
minimumPrimaryPanelPixels,
Math.min(this.maximumPaneSize ?? Infinity, maximumPrimaryPanelPixels ?? Infinity, pixelSize)
);
} else {
pixelSize = Math.max(minimumPrimaryPanelPixels, pixelSize);
}
percentSize = (pixelSize / this.currentContainerSize) * 100;
this.initialSize = pixelSize;
} else {
const maximumPrimaryPanelPercent = hasMinimumSecondaryPaneSize ? 100 - this.minimumSecondaryPaneSize : undefined;
const minimumPrimaryPanelPercent = maximumPrimaryPanelPercent === undefined
? this.minimumPaneSize
: Math.min(this.minimumPaneSize, maximumPrimaryPanelPercent);
if (this.maximumPaneSize || maximumPrimaryPanelPercent !== undefined) {
percentSize = Math.max(
minimumPrimaryPanelPercent,
Math.min(this.maximumPaneSize ?? Infinity, maximumPrimaryPanelPercent ?? Infinity, percentSize)
);
} else {
percentSize = Math.max(minimumPrimaryPanelPercent, percentSize);
}
pixelSize = (this.currentContainerSize / 100) * percentSize;
this.initialSize = percentSize;
}
this.currentPixelSize = pixelSize;
this.currentPercentSize = percentSize;
this.primaryFull = this.calculatePixels ? (this.currentPixelSize + 'px') : (this.currentPercentSize + '%');
}
_setFocusPane(idx: number) {
this._focusPane = idx;
this.dispatchEvent(new CustomEvent('zn-split-pane-focus-change', {bubbles: true, composed: true}));
}
private getDirectNestedSplitPanes() {
return Array.from(this.querySelectorAll('zn-split-pane')).filter(child => {
return child.parentElement?.closest('zn-split-pane') === this && !child.vertical;
});
}
private updateNestedNavigationMerging() {
const shouldMerge = !this.vertical && this.parentIsNarrow;
this.getDirectNestedSplitPanes().forEach(child => {
child.mergedNavigation = shouldMerge;
});
}
private getPaneIndexForNestedSplitPane(child: ZnSplitPane) {
let node: Element | null = child;
while (node && node.parentElement !== this) {
node = node.parentElement;
}
return node?.getAttribute('slot') === 'secondary' ? 1 : 0;
}
private getNestedNavigationItems(parentIdx: number): NavigationItem[] {
return this.getDirectNestedSplitPanesForPane(parentIdx)
.flatMap(child => child.getNavigationItems().map(item => {
return {
caption: item.caption,
active: this._focusPane === parentIdx && item.active,
select: () => {
this._setFocusPane(parentIdx);
item.select();
}
};
}));
}
private getDirectNestedSplitPanesForPane(parentIdx: number) {
return this.getDirectNestedSplitPanes().filter(child => this.getPaneIndexForNestedSplitPane(child) === parentIdx);
}
private getNavigationItems(): NavigationItem[] {
this.updateNestedNavigationMerging();
const primaryNestedItems = this.getNestedNavigationItems(0);
const secondaryNestedItems = this.getNestedNavigationItems(1);
const primaryItems = primaryNestedItems.length > 0
? primaryNestedItems
: [{
caption: this.primaryCaption,
active: this._focusPane === 0,
select: () => this._setFocusPane(0)
}];
const secondaryItems = secondaryNestedItems.length > 0
? secondaryNestedItems
: [{
caption: this.secondaryCaption,
active: this._focusPane === 1,
select: () => this._setFocusPane(1)
}];
return [
...primaryItems,
...secondaryItems
];
}
protected render(): unknown {
const resizeWidth = '2px';
const resizeMargin = '5px';
const minimumSecondaryPaneSize = Number.isFinite(this.minimumSecondaryPaneSize)
? this.minimumSecondaryPaneSize + (this.calculatePixels ? 'px' : '%')
: 'var(--min-panel-size)';
return html`
${this.getNavigationItems().map(item => html`
-
${item.caption}
`)}
this.requestUpdate()}">
this.requestUpdate()}">
`;
}
}