@trimble-oss/moduswebcomponents
Version:
Modus Web Components is a modern, accessible UI library built with Stencil JS that provides reusable web components following Trimble's Modus design system. This updated version focuses on improved flexibility, enhanced theming options, comprehensive cust
562 lines (561 loc) • 22.6 kB
JavaScript
import { h, Host } from "@stencil/core";
import { convertPropsToClasses } from "./modus-wc-handle.tailwind";
import { handleShadowDOMStyles } from "../base-component";
import { inheritAriaAttributes } from "../utils";
/**
* A draggable handle component for resizing adjacent elements
*/
export class ModusWcHandle {
constructor() {
this.inheritedAttributes = {};
this.startPos = 0;
this.startLeftSize = 0;
this.startRightSize = 0;
this.previousBodyCursor = null;
this.previousBodyUserSelect = null;
this.nonPassiveListenerOptions = {
passive: false,
};
/** Custom CSS class to apply to the handle element. */
this.customClass = '';
/** The orientation of the handle. */
this.orientation = 'horizontal';
/** The size of the handle. */
this.size = 'default';
/** The density/spacing of the handle container (compact: 8px, comfortable: 12px, relaxed: 16px). */
this.density = 'comfortable';
/** The initial split percentage for the left/top panel (1-100). The right/bottom panel gets the remaining percentage. */
this.defaultSplit = 50;
/** The size of the button. */
this.buttonSize = 'md';
/** The color of the button. */
this.buttonColor = 'tertiary';
/** The variant of the button. */
this.buttonVariant = 'filled';
/** The type of handle to display. */
this.type = 'bar';
/** Internal state for dragging */
this.isDragging = false;
this.handleMouseDown = (e) => {
if (this.type === 'bar') {
e.preventDefault();
}
this.startDrag(e.clientX, e.clientY);
};
this.handleTouchStart = (e) => {
if (this.type === 'bar') {
e.preventDefault();
}
const touch = e.touches[0];
this.startDrag(touch.clientX, touch.clientY);
};
this.handleMouseMove = (e) => {
if (!this.isDragging)
return;
e.preventDefault();
this.drag(e.clientX, e.clientY);
};
this.handleTouchMove = (e) => {
if (!this.isDragging)
return;
e.preventDefault();
const touch = e.touches[0];
this.drag(touch.clientX, touch.clientY);
};
this.handleMouseUp = () => {
this.endDrag();
};
this.handleTouchEnd = () => {
this.endDrag();
};
this.handleKeyDown = (e) => {
// Use larger increment when Shift key is held
const moveAmount = e.shiftKey ? 15 : 5; // pixels to move per key press
let delta = 0;
// Vertical orientation: up/down arrows
if (this.orientation === 'vertical') {
if (e.key === 'ArrowUp') {
delta = -moveAmount;
e.preventDefault();
}
else if (e.key === 'ArrowDown') {
delta = moveAmount;
e.preventDefault();
}
else {
return; // Ignore other keys
}
}
// Horizontal orientation: left/right arrows
else if (this.orientation === 'horizontal') {
if (e.key === 'ArrowLeft') {
delta = -moveAmount;
e.preventDefault();
}
else if (e.key === 'ArrowRight') {
delta = moveAmount;
e.preventDefault();
}
else {
return; // Ignore other keys
}
}
// Apply the resize
const leftEl = this.getTargetElement(this.leftTarget);
const rightEl = this.getTargetElement(this.rightTarget);
const leftSize = leftEl
? this.orientation === 'horizontal'
? leftEl.offsetWidth
: leftEl.offsetHeight
: 0;
const rightSize = rightEl
? this.orientation === 'horizontal'
? rightEl.offsetWidth
: rightEl.offsetHeight
: 0;
const clampedDelta = this.clampDelta(delta, leftEl, rightEl, leftSize, rightSize);
if (leftEl && this.orientation === 'vertical') {
const newHeight = leftSize + clampedDelta;
leftEl.style.height = `${newHeight}px`;
}
else if (leftEl && this.orientation === 'horizontal') {
const newWidth = leftSize + clampedDelta;
leftEl.style.width = `${newWidth}px`;
}
if (rightEl && this.orientation === 'vertical') {
const newHeight = rightSize - clampedDelta;
rightEl.style.height = `${newHeight}px`;
}
else if (rightEl && this.orientation === 'horizontal') {
const newWidth = rightSize - clampedDelta;
rightEl.style.width = `${newWidth}px`;
}
};
}
componentWillLoad() {
handleShadowDOMStyles(this.el);
this.inheritedAttributes = inheritAriaAttributes(this.el);
}
componentDidLoad() {
this.setupDragHandlers();
this.applyInitialSplit();
}
applyInitialSplit() {
if (!this.defaultSplit || !this.leftTarget || !this.rightTarget)
return;
const leftEl = this.getTargetElement(this.leftTarget);
const rightEl = this.getTargetElement(this.rightTarget);
// istanbul ignore next (unreachable code)
if (!leftEl || !rightEl)
return;
// Clamp split value between 1 and 100
const splitValue = Math.max(1, Math.min(100, this.defaultSplit));
const leftPercent = splitValue;
const rightPercent = 100 - splitValue;
if (this.orientation === 'horizontal') {
// Set width percentages for horizontal layout
leftEl.style.width = `${leftPercent}%`;
rightEl.style.width = `${rightPercent}%`;
// Prevent panels from shrinking below their set percentage
leftEl.style.flexShrink = '0';
rightEl.style.flexShrink = '0';
}
else {
// Set height percentages for vertical layout
leftEl.style.height = `${leftPercent}%`;
rightEl.style.height = `${rightPercent}%`;
// Prevent panels from shrinking below their set percentage
leftEl.style.flexShrink = '0';
rightEl.style.flexShrink = '0';
}
}
setupDragHandlers() {
// Mouse and touch events
this.el.addEventListener('mousedown', this.handleMouseDown);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
// Touch events for mobile support
this.el.addEventListener('touchstart', this.handleTouchStart, this.nonPassiveListenerOptions);
document.addEventListener('touchmove', this.handleTouchMove, this.nonPassiveListenerOptions);
document.addEventListener('touchend', this.handleTouchEnd);
// Keyboard navigation
this.el.addEventListener('keydown', this.handleKeyDown);
}
getTargetElement(target) {
if (!target)
return null;
if (typeof target === 'string') {
return document.querySelector(target);
}
return target;
}
startDrag(clientX, clientY) {
this.isDragging = true;
this.startPos = this.orientation === 'horizontal' ? clientX : clientY;
const leftEl = this.getTargetElement(this.leftTarget);
const rightEl = this.getTargetElement(this.rightTarget);
if (leftEl) {
this.startLeftSize =
this.orientation === 'horizontal'
? leftEl.offsetWidth
: leftEl.offsetHeight;
}
else {
this.startLeftSize = 0;
}
if (rightEl) {
this.startRightSize =
this.orientation === 'horizontal'
? rightEl.offsetWidth
: rightEl.offsetHeight;
}
else {
this.startRightSize = 0;
}
this.previousBodyCursor = document.body.style.cursor;
this.previousBodyUserSelect = document.body.style.userSelect;
document.body.style.cursor =
this.orientation === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
}
drag(clientX, clientY) {
const currentPos = this.orientation === 'horizontal' ? clientX : clientY;
const delta = currentPos - this.startPos;
const leftEl = this.getTargetElement(this.leftTarget);
const rightEl = this.getTargetElement(this.rightTarget);
const clampedDelta = this.clampDelta(delta, leftEl, rightEl, this.startLeftSize, this.startRightSize);
if (leftEl && this.orientation === 'horizontal') {
leftEl.style.width = `${this.startLeftSize + clampedDelta}px`;
}
else if (leftEl && this.orientation === 'vertical') {
leftEl.style.height = `${this.startLeftSize + clampedDelta}px`;
}
if (rightEl && this.orientation === 'horizontal') {
rightEl.style.width = `${this.startRightSize - clampedDelta}px`;
}
else if (rightEl && this.orientation === 'vertical') {
rightEl.style.height = `${this.startRightSize - clampedDelta}px`;
}
}
endDrag() {
var _a, _b;
this.isDragging = false;
document.body.style.cursor = (_a = this.previousBodyCursor) !== null && _a !== void 0 ? _a : '';
document.body.style.userSelect = (_b = this.previousBodyUserSelect) !== null && _b !== void 0 ? _b : '';
this.previousBodyCursor = null;
this.previousBodyUserSelect = null;
}
getMinSize(target, axis) {
const computedStyle = getComputedStyle(target);
const value = axis === 'horizontal' ? computedStyle.minWidth : computedStyle.minHeight;
const parsed = parseFloat(value);
// istanbul ignore next (unreachable code)
return Number.isNaN(parsed) ? 0 : parsed;
}
clampDelta(delta, leftEl, rightEl, leftSize, rightSize) {
let minDelta = Number.NEGATIVE_INFINITY;
let maxDelta = Number.POSITIVE_INFINITY;
const axis = this.orientation === 'vertical' ? 'vertical' : 'horizontal';
if (leftEl) {
const minLeft = this.getMinSize(leftEl, axis);
minDelta = Math.max(minDelta, minLeft - leftSize);
}
if (rightEl) {
const minRight = this.getMinSize(rightEl, axis);
maxDelta = Math.min(maxDelta, rightSize - minRight);
}
return Math.min(Math.max(delta, minDelta), maxDelta);
}
disconnectedCallback() {
// Determine the target element for removing event listeners
this.el.removeEventListener('mousedown', this.handleMouseDown);
this.el.removeEventListener('touchstart', this.handleTouchStart, this.nonPassiveListenerOptions);
this.el.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
document.removeEventListener('touchmove', this.handleTouchMove, this.nonPassiveListenerOptions);
document.removeEventListener('touchend', this.handleTouchEnd);
}
getClasses() {
const classList = ['modus-wc-handle-container'];
const propClasses = convertPropsToClasses({
orientation: this.orientation,
size: this.size,
type: this.type,
density: this.density,
});
if (propClasses)
classList.push(propClasses);
if (this.customClass)
classList.push(this.customClass);
return classList.join(' ');
}
renderBarHandle() {
return h("div", { class: "modus-wc-handle-bar" });
}
renderButtonHandle() {
const iconName = this.orientation === 'vertical' ? 'drag_vertical' : 'drag_horizontal';
return (h("div", { class: "modus-wc-handle-button-wrapper" }, h("modus-wc-button", { size: this.buttonSize, color: this.buttonColor, variant: this.buttonVariant, shape: "circle", "aria-orientation": this.orientation, role: "separator" }, h("modus-wc-icon", { name: iconName, size: "xs" }))));
}
render() {
return (h(Host, Object.assign({ key: '944adbf88619577d1d10058e336a995b6ff52a18', class: this.getClasses(), role: this.type === 'bar' ? 'separator' : undefined, tabIndex: 0, "aria-orientation": this.type === 'bar' ? this.orientation : undefined }, this.inheritedAttributes), this.type === 'bar'
? this.renderBarHandle()
: this.renderButtonHandle()));
}
static get is() { return "modus-wc-handle"; }
static get originalStyleUrls() {
return {
"$": ["modus-wc-handle.scss"]
};
}
static get styleUrls() {
return {
"$": ["modus-wc-handle.css"]
};
}
static get properties() {
return {
"customClass": {
"type": "string",
"attribute": "custom-class",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "Custom CSS class to apply to the handle element."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "''"
},
"leftTarget": {
"type": "string",
"attribute": "left-target",
"mutable": false,
"complexType": {
"original": "string | HTMLElement",
"resolved": "HTMLElement | string | undefined",
"references": {
"HTMLElement": {
"location": "global",
"id": "global::HTMLElement"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The left target element to resize (CSS selector or HTMLElement)"
},
"getter": false,
"setter": false,
"reflect": false
},
"orientation": {
"type": "string",
"attribute": "orientation",
"mutable": false,
"complexType": {
"original": "Orientation",
"resolved": "\"horizontal\" | \"vertical\" | undefined",
"references": {
"Orientation": {
"location": "import",
"path": "../types",
"id": "src/components/types.ts::Orientation"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The orientation of the handle."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'horizontal'"
},
"rightTarget": {
"type": "string",
"attribute": "right-target",
"mutable": false,
"complexType": {
"original": "string | HTMLElement",
"resolved": "HTMLElement | string | undefined",
"references": {
"HTMLElement": {
"location": "global",
"id": "global::HTMLElement"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The right target element to resize (CSS selector or HTMLElement)"
},
"getter": false,
"setter": false,
"reflect": false
},
"size": {
"type": "string",
"attribute": "size",
"mutable": false,
"complexType": {
"original": "'default' | 'lg' | 'xl' | '2xl'",
"resolved": "\"2xl\" | \"default\" | \"lg\" | \"xl\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The size of the handle."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'default'"
},
"density": {
"type": "string",
"attribute": "density",
"mutable": false,
"complexType": {
"original": "'compact' | 'comfortable' | 'relaxed'",
"resolved": "\"comfortable\" | \"compact\" | \"relaxed\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The density/spacing of the handle container (compact: 8px, comfortable: 12px, relaxed: 16px)."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'comfortable'"
},
"defaultSplit": {
"type": "number",
"attribute": "default-split",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The initial split percentage for the left/top panel (1-100). The right/bottom panel gets the remaining percentage."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "50"
},
"buttonSize": {
"type": "string",
"attribute": "button-size",
"mutable": false,
"complexType": {
"original": "DaisySize | 'xl'",
"resolved": "\"lg\" | \"md\" | \"sm\" | \"xl\" | \"xs\" | undefined",
"references": {
"DaisySize": {
"location": "import",
"path": "../types",
"id": "src/components/types.ts::DaisySize"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The size of the button."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'md'"
},
"buttonColor": {
"type": "string",
"attribute": "button-color",
"mutable": false,
"complexType": {
"original": "| 'primary'\n | 'secondary'\n | 'tertiary'\n | 'warning'\n | 'danger'",
"resolved": "\"danger\" | \"primary\" | \"secondary\" | \"tertiary\" | \"warning\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The color of the button."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'tertiary'"
},
"buttonVariant": {
"type": "string",
"attribute": "button-variant",
"mutable": false,
"complexType": {
"original": "'borderless' | 'filled' | 'outlined'",
"resolved": "\"borderless\" | \"filled\" | \"outlined\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The variant of the button."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'filled'"
},
"type": {
"type": "string",
"attribute": "type",
"mutable": false,
"complexType": {
"original": "'bar' | 'button'",
"resolved": "\"bar\" | \"button\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The type of handle to display."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'bar'"
}
};
}
static get elementRef() { return "el"; }
}