2021-12-22 23:32:27 +00:00
|
|
|
import { LitElement, html } from 'lit';
|
|
|
|
import { customElement, property, query } from 'lit/decorators.js';
|
|
|
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
|
|
import { clamp } from '../../internal/math';
|
|
|
|
import { emit } from '../../internal/event';
|
|
|
|
import { watch } from '../../internal/watch';
|
2021-12-23 00:07:16 +00:00
|
|
|
import { LocalizeController } from '../../utilities/localize';
|
2021-12-22 23:32:27 +00:00
|
|
|
import styles from './split-panel.styles';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @since 2.0
|
|
|
|
* @status experimental
|
|
|
|
*
|
2021-12-23 16:23:14 +00:00
|
|
|
* @event sl-reposition - Emitted when the divider's position changes.
|
2021-12-22 23:32:27 +00:00
|
|
|
*
|
2021-12-23 16:39:25 +00:00
|
|
|
* @csspart divider - The divider that separates the primary and secondary panels.
|
2021-12-23 00:07:16 +00:00
|
|
|
*
|
2021-12-22 23:32:27 +00:00
|
|
|
* @slot start - The start panel.
|
|
|
|
* @slot end - The end panel.
|
2021-12-23 00:07:16 +00:00
|
|
|
* @slot handle - An optional handle to render at the center of the divider.
|
2021-12-22 23:32:27 +00:00
|
|
|
*
|
|
|
|
* @cssproperty [--divider-width=4px] - The width of the visible divider.
|
2021-12-23 16:39:25 +00:00
|
|
|
* @cssproperty [--divider-hit-area=12px] - The invisible region around the divider where dragging can occur.
|
2021-12-23 15:07:37 +00:00
|
|
|
* @cssproperty [--min=0] - The minimum allowed size of the primary panel.
|
|
|
|
* @cssproperty [--max=100%] - The maximum allowed size of the primary panel.
|
2021-12-22 23:32:27 +00:00
|
|
|
*/
|
|
|
|
@customElement('sl-split-panel')
|
|
|
|
export default class SlSplitPanel extends LitElement {
|
|
|
|
static styles = styles;
|
|
|
|
|
2021-12-23 00:07:16 +00:00
|
|
|
private localize = new LocalizeController(this);
|
2021-12-23 16:23:14 +00:00
|
|
|
private positionPercentage: number;
|
2021-12-22 23:32:27 +00:00
|
|
|
private resizeObserver: ResizeObserver;
|
|
|
|
private size: number;
|
|
|
|
|
|
|
|
@query('.divider') divider: HTMLElement;
|
|
|
|
|
|
|
|
/**
|
2021-12-23 13:24:44 +00:00
|
|
|
* The current position of the divider from the primary panel's edge. Defaults to 50% of the container's intial size.
|
2021-12-22 23:32:27 +00:00
|
|
|
*/
|
|
|
|
@property({ type: Number, reflect: true }) position: number;
|
|
|
|
|
|
|
|
/** Draws the split panel in a vertical orientation with the start and end panels stacked. */
|
|
|
|
@property({ type: Boolean, reflect: true }) vertical = false;
|
|
|
|
|
|
|
|
/** Disables resizing on the split panel. */
|
|
|
|
@property({ type: Boolean, reflect: true }) disabled = false;
|
|
|
|
|
|
|
|
/**
|
2021-12-23 16:23:14 +00:00
|
|
|
* When the host element is resized, the primary panel will maintain its size and the other panel will grow or shrink
|
2021-12-23 16:39:25 +00:00
|
|
|
* to fit the remaining space. If no primary panel is designated, both panels will resize proportionally when the host
|
2021-12-23 16:23:14 +00:00
|
|
|
* element is resized.
|
2021-12-22 23:32:27 +00:00
|
|
|
*/
|
2021-12-23 16:23:14 +00:00
|
|
|
@property() primary: 'start' | 'end';
|
2021-12-22 23:32:27 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* One or more space-separated values at which the divider should snap. Values can be in pixels or percentages, e.g.
|
|
|
|
* `"100px 50%"`.
|
|
|
|
*/
|
|
|
|
@property() snap: string;
|
|
|
|
|
|
|
|
/** How close the divider must be to a snap point until snapping occurs. */
|
|
|
|
@property({ type: Number, attribute: 'snap-threshold' }) snapThreshold = 12;
|
|
|
|
|
|
|
|
connectedCallback() {
|
|
|
|
super.connectedCallback();
|
|
|
|
this.resizeObserver = new ResizeObserver(entries => this.handleResize(entries));
|
|
|
|
this.updateComplete.then(() => this.resizeObserver.observe(this));
|
|
|
|
|
|
|
|
const { width, height } = this.getBoundingClientRect();
|
|
|
|
this.size = this.vertical ? height : width;
|
|
|
|
|
|
|
|
if (!this.position) {
|
|
|
|
this.position = this.size / 2;
|
|
|
|
}
|
2021-12-23 16:23:14 +00:00
|
|
|
|
|
|
|
this.positionPercentage = this.getPositionAsPercentage();
|
2021-12-22 23:32:27 +00:00
|
|
|
}
|
2021-12-30 18:09:50 +00:00
|
|
|
|
|
|
|
disconnectedCallback() {
|
|
|
|
super.disconnectedCallback();
|
|
|
|
this.resizeObserver.unobserve(this);
|
|
|
|
}
|
2021-12-22 23:32:27 +00:00
|
|
|
|
2021-12-23 00:07:16 +00:00
|
|
|
handleDrag(event: Event) {
|
|
|
|
if (this.disabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prevent text selection when dragging
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
function drag(container: HTMLElement, onMove: (x: number, y: number) => void) {
|
|
|
|
const move = (event: any) => {
|
|
|
|
const dims = container.getBoundingClientRect();
|
|
|
|
const defaultView = container.ownerDocument.defaultView!;
|
|
|
|
const offsetX = dims.left + defaultView.pageXOffset;
|
|
|
|
const offsetY = dims.top + defaultView.pageYOffset;
|
|
|
|
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
|
|
|
|
const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY;
|
|
|
|
|
|
|
|
onMove(x, y);
|
|
|
|
};
|
|
|
|
|
|
|
|
const stop = () => {
|
|
|
|
document.removeEventListener('mousemove', move);
|
|
|
|
document.removeEventListener('touchmove', move);
|
|
|
|
document.removeEventListener('mouseup', stop);
|
|
|
|
document.removeEventListener('touchend', stop);
|
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', move, { passive: true });
|
|
|
|
document.addEventListener('touchmove', move, { passive: true });
|
|
|
|
document.addEventListener('mouseup', stop);
|
|
|
|
document.addEventListener('touchend', stop);
|
|
|
|
}
|
|
|
|
|
|
|
|
drag(this, (x, y) => {
|
|
|
|
let newPosition = this.vertical ? y : x;
|
|
|
|
|
|
|
|
// Flip for end panels
|
2021-12-23 13:24:44 +00:00
|
|
|
if (this.primary === 'end') {
|
2021-12-23 00:07:16 +00:00
|
|
|
newPosition = this.size - newPosition;
|
|
|
|
}
|
2021-12-22 23:32:27 +00:00
|
|
|
|
|
|
|
// Check snap points
|
|
|
|
if (this.snap) {
|
|
|
|
const snaps = this.snap.split(' ');
|
|
|
|
|
|
|
|
snaps.map(value => {
|
|
|
|
let snapPoint: number;
|
|
|
|
|
|
|
|
if (value.endsWith('%')) {
|
|
|
|
snapPoint = this.size * (parseFloat(value) / 100);
|
|
|
|
} else {
|
|
|
|
snapPoint = parseFloat(value);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (newPosition >= snapPoint - this.snapThreshold && newPosition <= snapPoint + this.snapThreshold) {
|
|
|
|
newPosition = snapPoint;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
this.position = clamp(newPosition, 0, this.size);
|
2021-12-23 16:23:14 +00:00
|
|
|
this.positionPercentage = this.getPositionAsPercentage();
|
2021-12-23 00:07:16 +00:00
|
|
|
});
|
2021-12-22 23:32:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
handleKeyDown(event: KeyboardEvent) {
|
|
|
|
if (this.disabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-12-27 15:31:57 +00:00
|
|
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
|
|
let newPercentage = this.getPositionAsPercentage();
|
|
|
|
let incr = (event.shiftKey ? 10 : 1) * (this.primary === 'end' ? -1 : 1);
|
2021-12-22 23:32:27 +00:00
|
|
|
|
|
|
|
event.preventDefault();
|
2021-12-27 15:31:57 +00:00
|
|
|
|
|
|
|
if ((event.key === 'ArrowLeft' && !this.vertical) || (event.key === 'ArrowUp' && this.vertical)) {
|
|
|
|
newPercentage -= incr;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ((event.key === 'ArrowRight' && !this.vertical) || (event.key === 'ArrowDown' && this.vertical)) {
|
|
|
|
newPercentage += incr;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event.key === 'Home') {
|
|
|
|
newPercentage = this.primary === 'end' ? 100 : 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event.key === 'End') {
|
|
|
|
newPercentage = this.primary === 'end' ? 0 : 100;
|
|
|
|
}
|
|
|
|
|
|
|
|
newPercentage = clamp(newPercentage, 0, 100);
|
|
|
|
|
2021-12-23 16:23:14 +00:00
|
|
|
this.setPositionAsPercentage(newPercentage);
|
2021-12-22 23:32:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@watch('position')
|
|
|
|
handlePositionChange() {
|
|
|
|
emit(this, 'sl-reposition');
|
|
|
|
}
|
|
|
|
|
|
|
|
handleResize(entries: ResizeObserverEntry[]) {
|
|
|
|
const { width, height } = entries[0].contentRect;
|
|
|
|
this.size = this.vertical ? height : width;
|
|
|
|
|
2021-12-23 16:23:14 +00:00
|
|
|
// Resize proportionally when a primary panel isn't set
|
|
|
|
if (!this.primary && this.positionPercentage) {
|
|
|
|
this.setPositionAsPercentage(this.positionPercentage);
|
|
|
|
}
|
2021-12-22 23:32:27 +00:00
|
|
|
}
|
|
|
|
|
2021-12-23 16:39:25 +00:00
|
|
|
/** Gets the divider's position as a percentage of the container's size (0-100). */
|
2021-12-23 16:23:14 +00:00
|
|
|
getPositionAsPercentage() {
|
2021-12-22 23:32:27 +00:00
|
|
|
if (this.size === 0) {
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (this.position / this.size) * 100;
|
|
|
|
}
|
|
|
|
|
2021-12-23 16:39:25 +00:00
|
|
|
/** Sets the divider position as a percentage of the container's size (0-100). */
|
2021-12-23 16:23:14 +00:00
|
|
|
setPositionAsPercentage(value: number) {
|
2021-12-22 23:32:27 +00:00
|
|
|
this.position = clamp(this.size * (value / 100), 0, this.size);
|
|
|
|
}
|
|
|
|
|
|
|
|
render() {
|
2021-12-23 15:07:37 +00:00
|
|
|
const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns';
|
|
|
|
const primary = `
|
|
|
|
clamp(
|
|
|
|
0%,
|
|
|
|
clamp(
|
|
|
|
var(--min),
|
2021-12-27 16:37:40 +00:00
|
|
|
${this.position}px - var(--divider-width) / 2,
|
2021-12-23 15:07:37 +00:00
|
|
|
var(--max)
|
|
|
|
),
|
|
|
|
calc(100% - var(--divider-width))
|
|
|
|
)
|
|
|
|
`;
|
|
|
|
const secondary = 'auto';
|
2021-12-22 23:32:27 +00:00
|
|
|
|
2021-12-23 13:24:44 +00:00
|
|
|
if (this.primary === 'end') {
|
2021-12-23 15:07:37 +00:00
|
|
|
this.style[gridTemplate] = `${secondary} var(--divider-width) ${primary}`;
|
2021-12-22 23:32:27 +00:00
|
|
|
} else {
|
2021-12-23 15:07:37 +00:00
|
|
|
this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`;
|
2021-12-22 23:32:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return html`
|
2021-12-23 15:07:37 +00:00
|
|
|
<div class="start">
|
2021-12-22 23:32:27 +00:00
|
|
|
<slot name="start"></slot>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div
|
2021-12-23 00:07:16 +00:00
|
|
|
part="divider"
|
2021-12-22 23:32:27 +00:00
|
|
|
class="divider"
|
|
|
|
tabindex=${ifDefined(this.disabled ? undefined : '0')}
|
2021-12-23 00:07:16 +00:00
|
|
|
role="separator"
|
2021-12-23 16:23:14 +00:00
|
|
|
aria-label=${this.localize.term('resize')}
|
2021-12-22 23:32:27 +00:00
|
|
|
@keydown=${this.handleKeyDown}
|
|
|
|
@mousedown=${this.handleDrag}
|
|
|
|
@touchstart=${this.handleDrag}
|
2021-12-23 00:07:16 +00:00
|
|
|
>
|
|
|
|
<slot name="handle"></slot>
|
|
|
|
</div>
|
2021-12-22 23:32:27 +00:00
|
|
|
|
2021-12-23 15:07:37 +00:00
|
|
|
<div class="end">
|
2021-12-22 23:32:27 +00:00
|
|
|
<slot name="end"></slot>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
interface HTMLElementTagNameMap {
|
|
|
|
'sl-split-panel': SlSplitPanel;
|
|
|
|
}
|
|
|
|
}
|