@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
513 lines (512 loc) • 23.6 kB
JavaScript
import { h, Host, } from "@stencil/core";
import { LOGO_VARIANTS } from "../modus-wc-logo/logo-constants";
import { inheritAriaAttributes } from "../utils";
import { focusAppMenuItem, getGridColumnCount, getNavigationOffset, getTargetFocusIndex, reorderGridItem, reorderListItem, } from "./utils/app-menu-keyboard";
export class ModusWcAppMenu {
constructor() {
this.inheritedAttributes = {};
/** custom class to apply to the menu */
this.customClass = '';
/** The layout of the menu. */
this.layout = 'list';
/** The apps to display in the menu. */
this.apps = [];
this.isEditMode = false;
this.draggedItemPos = null;
this.dropTargetIndex = null;
this.grabbedItemPos = null;
this.truncatedApps = new Set();
this.appsSnapshot = null;
}
componentWillLoad() {
this.inheritedAttributes = inheritAriaAttributes(this.el);
}
componentDidLoad() {
this.scheduleTooltipUpdate();
}
onAppsChange() {
if (!this.isEditMode) {
this.scheduleTooltipUpdate();
}
}
scheduleTooltipUpdate() {
requestAnimationFrame(() => {
if (this.layout === 'grid') {
this.updateGridTooltips();
}
else {
this.updateListTooltips();
}
});
}
setsAreEqual(a, b) {
if (a.size !== b.size)
return false;
for (const name of a) {
if (!b.has(name))
return false;
}
return true;
}
setTruncatedApps(next) {
if (this.setsAreEqual(next, this.truncatedApps))
return;
this.truncatedApps = next;
}
updateGridTooltips() {
const updated = new Set();
const gridItems = this.el.querySelectorAll('.modus-wc-app-menu-grid-item');
gridItems.forEach((gridItem, appIndex) => {
var _a, _b;
const label = gridItem.querySelector('.modus-wc-app-menu-grid-item-label');
const appName = (_b = (_a = this.apps) === null || _a === void 0 ? void 0 : _a[appIndex]) === null || _b === void 0 ? void 0 : _b.appName;
if (label && appName) {
const isTruncated = label.scrollWidth > label.clientWidth ||
label.scrollHeight > label.clientHeight;
if (isTruncated) {
updated.add(appName);
}
}
});
this.setTruncatedApps(updated);
}
/** Label text node that visually truncates (inner div), not `.modus-wc-menu-item-labels`. */
getListMenuItemLabelTextElement(row) {
var _a, _b;
const labels = row.querySelector('.modus-wc-menu-item-labels');
if (!labels)
return null;
// Walk direct children only; `:scope >` is unavailable in mock-doc (used in tests).
for (const child of Array.from(labels.children)) {
if (child.tagName === 'MODUS-WC-TOOLTIP') {
const textDiv = (_b = (_a = child.firstElementChild) === null || _a === void 0 ? void 0 : _a.firstElementChild) !== null && _b !== void 0 ? _b : null;
return textDiv;
}
if (child.tagName === 'DIV' &&
!child.classList.contains('modus-wc-menu-item-sublabel')) {
return child;
}
}
return null;
}
updateListTooltips() {
const updated = new Set();
const rows = this.el.querySelectorAll('.modus-wc-app-menu-item-row');
rows.forEach((row, appIndex) => {
var _a, _b;
const appName = (_b = (_a = this.apps) === null || _a === void 0 ? void 0 : _a[appIndex]) === null || _b === void 0 ? void 0 : _b.appName;
if (!appName)
return;
const textEl = this.getListMenuItemLabelTextElement(row);
if (!textEl)
return;
const isTruncated = textEl.scrollWidth > textEl.clientWidth ||
textEl.scrollHeight > textEl.clientHeight;
if (isTruncated) {
updated.add(appName);
}
});
this.setTruncatedApps(updated);
}
getDisplayName(appName) {
var _a, _b;
return (_b = (_a = LOGO_VARIANTS[appName]) === null || _a === void 0 ? void 0 : _a.displayName) !== null && _b !== void 0 ? _b : appName;
}
onLayoutChange(newLayout) {
this.layoutChange.emit({ layout: newLayout });
this.scheduleTooltipUpdate();
}
handleEdit() {
var _a, _b;
this.appsSnapshot = [...((_a = this.apps) !== null && _a !== void 0 ? _a : [])];
this.isEditMode = true;
const layout = (_b = this.layout) !== null && _b !== void 0 ? _b : 'list';
requestAnimationFrame(() => {
focusAppMenuItem(this.el, layout, 0);
});
}
hasOrderChangedSinceEdit() {
var _a;
const snapshot = this.appsSnapshot;
if (!snapshot)
return false;
const current = (_a = this.apps) !== null && _a !== void 0 ? _a : [];
if (current.length !== snapshot.length)
return true;
// Length equality guarantees snapshot[i] is defined here.
return current.some((item, i) => item.appName !== snapshot[i].appName);
}
handleDone() {
var _a;
const shouldEmit = this.hasOrderChangedSinceEdit();
this.isEditMode = false;
this.grabbedItemPos = null;
this.appsSnapshot = null;
if (shouldEmit) {
this.itemsOrderChange.emit([...((_a = this.apps) !== null && _a !== void 0 ? _a : [])]);
}
}
handleCancel() {
if (this.appsSnapshot) {
this.apps = this.appsSnapshot;
}
this.appsSnapshot = null;
this.isEditMode = false;
this.grabbedItemPos = null;
}
handleKeyDown(e, appIndex) {
var _a, _b;
switch (e.key) {
case ' ':
case 'Enter': {
if (this.isEditMode) {
e.preventDefault();
this.grabbedItemPos = this.grabbedItemPos ? null : { appIndex };
}
else {
const app = (_a = this.apps) === null || _a === void 0 ? void 0 : _a[appIndex];
if (app) {
e.preventDefault();
this.itemClick.emit({ appName: app.appName });
}
}
break;
}
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight': {
const layout = (_b = this.layout) !== null && _b !== void 0 ? _b : 'list';
const gridCols = layout === 'grid' ? getGridColumnCount(this.el) : 1;
const offset = getNavigationOffset(e.key, layout, gridCols);
if (offset === null)
return;
e.preventDefault();
e.stopPropagation();
if (this.isEditMode && this.grabbedItemPos) {
this.reorderByKeyboard(appIndex, offset);
}
else {
this.navigateFocusByKeyboard(appIndex, offset);
}
break;
}
case 'Escape':
if (this.isEditMode && this.grabbedItemPos) {
e.preventDefault();
this.grabbedItemPos = null;
}
break;
}
}
reorderByKeyboard(appIndex, offset) {
var _a, _b;
const apps = (_a = this.apps) !== null && _a !== void 0 ? _a : [];
const layout = (_b = this.layout) !== null && _b !== void 0 ? _b : 'list';
const result = layout === 'grid'
? reorderGridItem(apps, appIndex, offset)
: reorderListItem(apps, appIndex, offset);
if (result) {
this.apps = result.items;
this.grabbedItemPos = { appIndex: result.targetIndex };
requestAnimationFrame(() => {
focusAppMenuItem(this.el, layout, result.targetIndex);
});
}
}
navigateFocusByKeyboard(appIndex, offset) {
var _a, _b, _c;
const layout = (_a = this.layout) !== null && _a !== void 0 ? _a : 'list';
const targetIdx = getTargetFocusIndex(appIndex, offset, (_c = (_b = this.apps) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0);
if (targetIdx !== null) {
requestAnimationFrame(() => {
focusAppMenuItem(this.el, layout, targetIdx);
});
}
}
isGrabbed(appIndex) {
var _a;
return ((_a = this.grabbedItemPos) === null || _a === void 0 ? void 0 : _a.appIndex) === appIndex;
}
// Release the grab when keyboard focus moves out of the grabbed row,
// otherwise the previously grabbed row keeps its outline alongside the
// newly focused one (e.g., after pressing Tab).
handleRowFocusOut(e, appIndex) {
if (!this.isGrabbed(appIndex))
return;
const row = e.currentTarget;
const next = e.relatedTarget;
if (row && next && row.contains(next))
return;
this.grabbedItemPos = null;
}
isDragSource(appIndex) {
var _a;
return ((_a = this.draggedItemPos) === null || _a === void 0 ? void 0 : _a.appIndex) === appIndex;
}
handleDragStart(e, appIndex) {
if (!this.isEditMode)
return;
this.draggedItemPos = { appIndex };
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
}
handleDragOver(e) {
if (!this.isEditMode)
return;
e.preventDefault();
}
handleDragEnter(e, appIndex) {
if (!this.isEditMode || !this.draggedItemPos)
return;
e.preventDefault();
this.dropTargetIndex =
this.draggedItemPos.appIndex !== appIndex ? appIndex : null;
}
handleDragEnd() {
this.draggedItemPos = null;
this.dropTargetIndex = null;
}
handleDragLeave(e) {
const container = e.currentTarget;
const related = e.relatedTarget;
if (!related || !container.contains(related)) {
this.dropTargetIndex = null;
}
}
handleDrop(e, targetAppIndex) {
var _a;
if (!this.isEditMode || !this.draggedItemPos)
return;
e.preventDefault();
e.stopPropagation();
const apps = [...((_a = this.apps) !== null && _a !== void 0 ? _a : [])];
const { appIndex } = this.draggedItemPos;
const [movedItem] = apps.splice(appIndex, 1);
if (!movedItem)
return;
apps.splice(targetAppIndex, 0, movedItem);
this.apps = apps;
this.draggedItemPos = null;
this.dropTargetIndex = null;
}
handleContainerDrop(e) {
var _a;
if (!this.isEditMode || !this.draggedItemPos)
return;
e.preventDefault();
const apps = [...((_a = this.apps) !== null && _a !== void 0 ? _a : [])];
const { appIndex } = this.draggedItemPos;
const [movedItem] = apps.splice(appIndex, 1);
if (!movedItem)
return;
apps.push(movedItem);
this.apps = apps;
this.draggedItemPos = null;
this.dropTargetIndex = null;
}
renderListLayout() {
var _a;
const apps = (_a = this.apps) !== null && _a !== void 0 ? _a : [];
return (h("div", { class: "modus-wc-app-menu-items", onDragLeave: (e) => this.handleDragLeave(e), onDragOver: (e) => this.handleDragOver(e), onDrop: (e) => this.handleContainerDrop(e) }, h("modus-wc-menu", null, apps.map((app, appIndex) => (h("div", { "aria-label": this.getDisplayName(app.appName), "aria-roledescription": this.isEditMode ? 'reorderable item' : undefined, class: `modus-wc-app-menu-item-row ${this.isEditMode ? 'modus-wc-app-menu-draggable-item' : ''} ${this.isGrabbed(appIndex) ? 'modus-wc-app-menu-grabbed-item' : ''} ${this.isDragSource(appIndex) ? 'modus-wc-app-menu-drag-source' : ''} ${this.dropTargetIndex === appIndex ? 'modus-wc-app-menu-drop-target' : ''}`, draggable: this.isEditMode, onClick: () => {
if (!this.isEditMode) {
this.itemClick.emit({ appName: app.appName });
}
}, onDragEnd: () => this.handleDragEnd(), onDragEnter: (e) => this.handleDragEnter(e, appIndex), onDragOver: (e) => this.handleDragOver(e), onDragStart: (e) => this.handleDragStart(e, appIndex), onDrop: (e) => this.handleDrop(e, appIndex), onFocusout: (e) => this.handleRowFocusOut(e, appIndex), onKeyDown: (e) => this.handleKeyDown(e, appIndex), role: this.isEditMode ? 'option' : 'listitem', tabindex: this.isEditMode ? 0 : -1 }, this.isEditMode && (h("modus-wc-icon", { name: "drag_indicator", "custom-class": "modus-wc-app-menu-drag-icon", size: "xs" })), h("modus-wc-menu-item", { label: this.getDisplayName(app.appName), tooltipContent: this.truncatedApps.has(app.appName)
? this.getDisplayName(app.appName)
: undefined, tooltipPosition: "auto", onItemSelect: (e) => e.stopPropagation() }, h("modus-wc-logo", { name: app.appName, "custom-class": "modus-wc-app-menu-app-logo", emblem: true, slot: "start-icon" }))))))));
}
renderGridLayout() {
var _a;
const apps = (_a = this.apps) !== null && _a !== void 0 ? _a : [];
return (h("div", { class: "modus-wc-app-menu-grid", onDragLeave: (e) => this.handleDragLeave(e), onDragOver: (e) => this.handleDragOver(e), onDrop: (e) => this.handleContainerDrop(e) }, h("div", { class: "modus-wc-app-menu-grid-row", role: this.isEditMode ? 'listbox' : 'list' }, apps.map((app, appIndex) => (
// Intentionally unkeyed: Stencil's keyed reconciliation triggers
// insertBefore on modus-wc-app-menu-grid-item DOM nodes during a reorder, which fires
// a transient disconnectedCallback on descendant custom elements.
// modus-wc-tooltip only sets up its popper/popover in
// componentDidLoad (runs once), so after the disconnect/reconnect
// its popover is detached from document.body and hover stops
// showing the tooltip for any moved item. Reusing nodes by
// position keeps the tooltip subtree connected.
h("div", { "aria-label": this.getDisplayName(app.appName), "aria-roledescription": this.isEditMode ? 'reorderable item' : undefined, class: `modus-wc-app-menu-grid-item ${this.isEditMode ? 'modus-wc-app-menu-draggable-item' : ''} ${this.isGrabbed(appIndex) ? 'modus-wc-app-menu-grabbed-item' : ''} ${this.isDragSource(appIndex) ? 'modus-wc-app-menu-drag-source' : ''} ${this.dropTargetIndex === appIndex ? 'modus-wc-app-menu-drop-target' : ''}`, draggable: this.isEditMode, onClick: () => {
if (!this.isEditMode) {
this.itemClick.emit({ appName: app.appName });
}
}, onDragEnd: () => this.handleDragEnd(), onDragEnter: (e) => this.handleDragEnter(e, appIndex), onDragOver: (e) => this.handleDragOver(e), onDragStart: (e) => this.handleDragStart(e, appIndex), onDrop: (e) => this.handleDrop(e, appIndex), onFocusout: (e) => this.handleRowFocusOut(e, appIndex), onKeyDown: (e) => this.handleKeyDown(e, appIndex), role: this.isEditMode ? 'option' : 'listitem', tabindex: 0 }, this.isEditMode && (h("modus-wc-icon", { name: "drag_indicator", "custom-class": "modus-wc-app-menu-drag-icon", size: "xs" })), h("modus-wc-logo", { name: app.appName, "custom-class": "modus-wc-app-menu-grid-emblem", emblem: true }), h("modus-wc-tooltip", { content: this.getDisplayName(app.appName), disabled: !this.truncatedApps.has(app.appName), position: "auto" }, h("modus-wc-typography", { "custom-class": "modus-wc-app-menu-grid-item-label", size: "sm", label: this.getDisplayName(app.appName) }))))))));
}
getClasses() {
const classList = ['modus-wc-app-menu'];
// Append any consumer-provided class alongside the base component class.
if (this.customClass)
classList.push(this.customClass);
return classList.join(' ');
}
render() {
return (h(Host, Object.assign({ key: 'a51b600d32deb9b1c554382d2011c39a0392bddb' }, this.inheritedAttributes, { class: this.getClasses() }), h("modus-wc-panel", { key: 'b1374169662b8c55102b28c29bf814a2f9624ddb', height: "568px", width: "320px" }, h("div", { key: '093037f4f705b9151e5914862e89eb888e5a98bd', slot: "body" }, h("div", { key: 'fb47edc9a60a1f23c5426c83d558bc739cdf1e39', class: "modus-wc-app-menu-header" }, h("div", { key: '9a341355eadd42521e25cd64afa18ac7d88c4da3', class: "modus-wc-app-menu-header-title" }, h("modus-wc-typography", { key: 'c610e3af5b8374a22a1c2730387def677498928d', size: "2xl", weight: "semibold", hierarchy: "h3", label: this.isEditMode ? 'Edit' : 'Trimble Apps' })), h("div", { key: 'f66e9c0044054497628e90bca7aae50fdebe483c', class: "modus-wc-app-menu-header-end-content" }, !this.isEditMode ? (h("modus-wc-button", { "aria-label": "Edit app order", shape: "square", size: "sm", variant: "filled", color: "tertiary", onButtonClick: () => this.handleEdit() }, h("modus-wc-icon", { name: "pencil", variant: "solid" }))) : ([
h("modus-wc-button", { size: "sm", color: "tertiary", onButtonClick: () => this.handleCancel() }, "Cancel"),
h("modus-wc-button", { size: "sm", color: "primary", onButtonClick: () => this.handleDone() }, "Done"),
]))), h("div", { key: '290ad2a3985cc6ab149b90b6bbd2876a29dbaa5a', class: "modus-wc-app-menu-body" }, this.layout === 'list'
? this.renderListLayout()
: this.renderGridLayout())))));
}
static get is() { return "modus-wc-app-menu"; }
static get originalStyleUrls() {
return {
"$": ["modus-wc-app-menu.scss"]
};
}
static get styleUrls() {
return {
"$": ["modus-wc-app-menu.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 class to apply to the menu"
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "''"
},
"layout": {
"type": "string",
"attribute": "layout",
"mutable": true,
"complexType": {
"original": "'list' | 'grid'",
"resolved": "\"grid\" | \"list\" | undefined",
"references": {}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The layout of the menu."
},
"getter": false,
"setter": false,
"reflect": false,
"defaultValue": "'list'"
},
"apps": {
"type": "unknown",
"attribute": "apps",
"mutable": true,
"complexType": {
"original": "IAppMenuItem[]",
"resolved": "IAppMenuItem[] | undefined",
"references": {
"IAppMenuItem": {
"location": "local",
"path": "/home/runner/work/modus-wc-2.0/modus-wc-2.0/src/components/modus-wc-app-menu/modus-wc-app-menu.tsx",
"id": "src/components/modus-wc-app-menu/modus-wc-app-menu.tsx::IAppMenuItem"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The apps to display in the menu."
},
"getter": false,
"setter": false,
"defaultValue": "[]"
}
};
}
static get states() {
return {
"isEditMode": {},
"draggedItemPos": {},
"dropTargetIndex": {},
"grabbedItemPos": {},
"truncatedApps": {}
};
}
static get events() {
return [{
"method": "layoutChange",
"name": "layoutChange",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emit event when the layout changes"
},
"complexType": {
"original": "{\n layout: 'list' | 'grid';\n }",
"resolved": "{ layout: \"list\" | \"grid\"; }",
"references": {}
}
}, {
"method": "itemsOrderChange",
"name": "itemsOrderChange",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when reordering is confirmed via \"Done\" and the order differs from when edit started"
},
"complexType": {
"original": "IAppMenuItem[]",
"resolved": "IAppMenuItem[]",
"references": {
"IAppMenuItem": {
"location": "local",
"path": "/home/runner/work/modus-wc-2.0/modus-wc-2.0/src/components/modus-wc-app-menu/modus-wc-app-menu.tsx",
"id": "src/components/modus-wc-app-menu/modus-wc-app-menu.tsx::IAppMenuItem"
}
}
}
}, {
"method": "itemClick",
"name": "itemClick",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when an item is clicked"
},
"complexType": {
"original": "{ appName: AppName }",
"resolved": "{ appName: LogoName; }",
"references": {
"AppName": {
"location": "import",
"path": "../types",
"id": "src/components/types.ts::AppName"
}
}
}
}];
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "apps",
"methodName": "onAppsChange"
}, {
"propName": "layout",
"methodName": "onLayoutChange"
}];
}
}