@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
558 lines (490 loc) • 18.9 kB
JavaScript
import { CONNECT_ICON_FONT_URL, CONNECT_ICONS, connectIconClass, } from "./modus-wc-side-navigation-connect-icons.story";
import { SIDE_NAV_DATA_FLYOUT_DROPDOWN_CLASS, SIDE_NAV_DATA_FLYOUT_MENU_GAP, SIDE_NAV_DATA_FLYOUT_MENU_OFFSET, SIDE_NAV_TREE_ITEM_END_ACTION_CLASS, SIDE_NAV_TREE_ITEM_END_ACTION_DROPDOWN_CLASS, SIDE_NAV_TREE_ITEM_END_ACTION_ICON_CLASS, sideNavConnectCollapsedRailStyles, sideNavConnectLightTreeItemEndSlotActiveStyles, sideNavConnectStoryLayoutStyles, sideNavConnectTreeItemStyles, sideNavConnectWithTreeMenuStoryStyles, sideNavDataFlyoutDropdownStyles, sideNavTreeItemEndActionDropdownStyles, } from "./modus-wc-side-navigation-tree-item-end-action.story-styles";
import { WITH_TREE_MENU_NAVBAR_USER_CARD, WITH_TREE_MENU_NAVBAR_VISIBILITY, withTreeMenuLayoutStyles, } from "./modus-wc-side-navigation-with-tree-menu.story-demo";
import { WITH_TREE_MENU_SIDE_NAV_COLLAPSE_MS } from "./modus-wc-side-navigation-with-tree-menu.story-handlers";
const navbarUserCardJson = JSON.stringify(WITH_TREE_MENU_NAVBAR_USER_CARD);
const navbarVisibilityJson = JSON.stringify(WITH_TREE_MENU_NAVBAR_VISIBILITY);
/** Consumer-facing Show code for the WithTreeMenu side-navigation story. */
export const getWithTreeMenuSourceCode = () => `
<link rel="stylesheet" href="${CONNECT_ICON_FONT_URL}" />
<style>
${withTreeMenuLayoutStyles}
${sideNavConnectCollapsedRailStyles}
${sideNavConnectTreeItemStyles}
${sideNavConnectLightTreeItemEndSlotActiveStyles}
${sideNavTreeItemEndActionDropdownStyles}
${sideNavDataFlyoutDropdownStyles}
${sideNavConnectStoryLayoutStyles}
${sideNavConnectWithTreeMenuStoryStyles}
</style>
<div class="layout-with-navbar">
<modus-wc-navbar
app-title="Modus App"
class="navbar"
id="connect-navbar"
style="z-index: 2;"
></modus-wc-navbar>
<div class="main-content-row">
<!-- Optional Connect collapsed rail: custom-class="min-width: 15rem !important;" on side-navigation -->
<modus-wc-side-navigation
class="side-navigation"
id="connect-side-nav"
expanded="true"
collapse-on-click-outside="true"
mode="push"
max-width="256px"
target-content=".panel-content"
>
<modus-wc-tree-menu size="lg" aria-label="Project navigation">
<modus-wc-tree-item label="All Projects" value="all-projects">
<modus-wc-icon
slot="start"
aria-label="All Projects icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.allProjects)}"
></modus-wc-icon>
</modus-wc-tree-item>
<!-- Data start slot: Connect uses flyout dropdown below.
Modern/Classic: use <modus-wc-icon slot="start" name="master_data" size="sm" aria-label="Data icon"></modus-wc-icon> (no dropdown). -->
<modus-wc-tree-item
id="data-tree-item"
label="Data"
value="data"
has-submenu="true"
>
<modus-wc-dropdown-menu
slot="start"
id="data-flyout-dropdown"
menu-placement="right-start"
menu-strategy="fixed"
menu-offset="${SIDE_NAV_DATA_FLYOUT_MENU_OFFSET}"
menu-size="lg"
button-variant="borderless"
custom-class="${SIDE_NAV_DATA_FLYOUT_DROPDOWN_CLASS}"
>
<modus-wc-icon
slot="button"
aria-label="Data icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.data)}"
></modus-wc-icon>
<modus-wc-menu-item slot="menu" label="Explorer" value="explorer" size="lg">
<modus-wc-icon
slot="start-icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.explorer)}"
></modus-wc-icon>
</modus-wc-menu-item>
<modus-wc-menu-item slot="menu" label="Views" value="views" size="lg">
<modus-wc-icon
slot="start-icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.views)}"
></modus-wc-icon>
</modus-wc-menu-item>
<modus-wc-menu-item slot="menu" label="Releases" value="releases" size="lg">
<modus-wc-icon
slot="start-icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.releases)}"
></modus-wc-icon>
</modus-wc-menu-item>
</modus-wc-dropdown-menu>
<modus-wc-tree-menu is-sub-menu="true">
<modus-wc-tree-item
label="Explorer"
value="explorer"
custom-class="${SIDE_NAV_TREE_ITEM_END_ACTION_CLASS}"
>
<modus-wc-icon
slot="start"
aria-label="Explorer icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.explorer)}"
></modus-wc-icon>
<!-- Explorer end-slot action: Connect only. Omit this block on Modern/Classic. -->
<div slot="end" style="display: flex; align-items: stretch;">
<div style="width: 1px; background: currentColor; opacity: 0.3;"></div>
<modus-wc-dropdown-menu
button-variant="borderless"
button-size="sm"
menu-size="sm"
menu-placement="right-start"
menu-strategy="fixed"
menu-offset="0"
button-aria-label="Open folder"
custom-class="${SIDE_NAV_TREE_ITEM_END_ACTION_DROPDOWN_CLASS}"
>
<div slot="button" style="display: flex; align-items: center; gap: 2px;">
<modus-wc-icon
aria-label="Folder icon"
name=""
size="sm"
custom-class="${connectIconClass(CONNECT_ICONS.folder, SIDE_NAV_TREE_ITEM_END_ACTION_ICON_CLASS, 'i16')}"
></modus-wc-icon>
<modus-wc-icon
aria-label="Open submenu icon"
name=""
size="sm"
custom-class="${connectIconClass(CONNECT_ICONS.chevronRight, SIDE_NAV_TREE_ITEM_END_ACTION_ICON_CLASS, 'i16')}"
></modus-wc-icon>
</div>
<modus-wc-menu-item slot="menu" label="Rename" value="rename"></modus-wc-menu-item>
<modus-wc-menu-item slot="menu" label="Duplicate" value="duplicate"></modus-wc-menu-item>
<modus-wc-menu-item slot="menu" label="Delete" value="delete"></modus-wc-menu-item>
</modus-wc-dropdown-menu>
</div>
</modus-wc-tree-item>
<modus-wc-tree-item label="Views" value="views">
<modus-wc-icon
slot="start"
aria-label="Views icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.views)}"
></modus-wc-icon>
</modus-wc-tree-item>
<modus-wc-tree-item label="Releases" value="releases">
<modus-wc-icon
slot="start"
aria-label="Releases icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.releases)}"
></modus-wc-icon>
</modus-wc-tree-item>
</modus-wc-tree-menu>
</modus-wc-tree-item>
<modus-wc-tree-item label="Activity" value="activity">
<modus-wc-icon
slot="start"
aria-label="Activity icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.activity)}"
></modus-wc-icon>
</modus-wc-tree-item>
<modus-wc-tree-item label="BCF Topics" value="bcf-topics">
<modus-wc-icon
slot="start"
aria-label="BCF Topics icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.bcfTopics)}"
></modus-wc-icon>
</modus-wc-tree-item>
<modus-wc-tree-item label="Field Data" value="field-data">
<modus-wc-icon
slot="start"
aria-label="Field Data icon"
name=""
custom-class="${connectIconClass(CONNECT_ICONS.fieldData)}"
></modus-wc-icon>
</modus-wc-tree-item>
</modus-wc-tree-menu>
</modus-wc-side-navigation>
<div class="panel-content">
<h3>Side Navigation with Tree Menu</h3>
<p>
Connect theme demo: expand the side nav with the navbar hamburger.
Data uses a flyout when collapsed and an inline submenu when expanded.
</p>
</div>
</div>
</div>
<script>
const SIDE_NAV_DATA_FLYOUT_MENU_GAP = ${SIDE_NAV_DATA_FLYOUT_MENU_GAP};
const SIDE_NAV_COLLAPSE_MS = ${WITH_TREE_MENU_SIDE_NAV_COLLAPSE_MS};
const WITH_TREE_MENU_NAVBAR_USER_CARD = ${navbarUserCardJson};
const WITH_TREE_MENU_NAVBAR_VISIBILITY = ${navbarVisibilityJson};
const isConnectSideNavTheme = () => {
const theme = document.documentElement.getAttribute('data-theme') ?? '';
return theme === 'connect-light' || theme === 'connect-dark';
};
const sideNav = document.getElementById('connect-side-nav');
const dataFlyoutDropdown = document.getElementById('data-flyout-dropdown');
const dataTreeItem = document.getElementById('data-tree-item');
let flyoutOpenTimer = null;
let collapseFlyoutTimer = null;
let expandedChangeReady = false;
let suppressNextMenuOpen = false;
const cancelPendingFlyoutOpen = () => {
if (flyoutOpenTimer) {
clearTimeout(flyoutOpenTimer);
flyoutOpenTimer = null;
}
if (collapseFlyoutTimer) {
clearTimeout(collapseFlyoutTimer);
collapseFlyoutTimer = null;
}
};
const hideFlyout = () => {
if (!isConnectSideNavTheme()) return;
cancelPendingFlyoutOpen();
if (dataFlyoutDropdown) {
dataFlyoutDropdown.menuVisible = false;
}
};
const setDataFlyoutDisabled = (disabled) => {
if (!dataFlyoutDropdown) return;
dataFlyoutDropdown.disabled = disabled;
};
const syncDataFlyoutMenuOffset = () => {
if (!dataFlyoutDropdown) return;
const sideNavHost = dataFlyoutDropdown.closest('modus-wc-side-navigation');
const navPanel = sideNavHost?.querySelector('.modus-wc-side-navigation');
const button = dataFlyoutDropdown.querySelector('modus-wc-button');
if (!navPanel || !button) return;
const navRect = navPanel.getBoundingClientRect();
const btnRect = button.getBoundingClientRect();
const triggerRect = dataFlyoutDropdown.getBoundingClientRect();
const anchorRight = Math.max(btnRect.right, triggerRect.right);
const offset = Math.round(
navRect.right - anchorRight + SIDE_NAV_DATA_FLYOUT_MENU_GAP
);
dataFlyoutDropdown.menuOffset = offset;
};
const openDataFlyout = (defer = false) => {
if (!isConnectSideNavTheme()) return;
cancelPendingFlyoutOpen();
const open = () => {
flyoutOpenTimer = null;
if (dataFlyoutDropdown) {
syncDataFlyoutMenuOffset();
dataFlyoutDropdown.menuVisible = true;
}
};
if (defer) {
flyoutOpenTimer = setTimeout(open, 0);
} else {
open();
}
};
const initConnectNavbar = () => {
const navbar = document.getElementById('connect-navbar');
if (!navbar) return;
navbar.userCard = WITH_TREE_MENU_NAVBAR_USER_CARD;
navbar.visibility = WITH_TREE_MENU_NAVBAR_VISIBILITY;
};
const initDataFlyoutDisabled = () => {
if (!dataFlyoutDropdown) return;
if (!isConnectSideNavTheme()) {
setDataFlyoutDisabled(true);
return;
}
setDataFlyoutDisabled(Boolean(sideNav?.expanded));
};
const resetForNonConnectTheme = () => {
hideFlyout();
setDataFlyoutDisabled(true);
sideNav?.querySelectorAll('modus-wc-tree-item').forEach((treeItem) => {
if (treeItem.hasSubmenu) {
treeItem.blockExpand = false;
}
});
};
new MutationObserver(() => {
if (!isConnectSideNavTheme()) {
resetForNonConnectTheme();
}
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
const getSelectedTreeItem = () => {
const items = sideNav.querySelectorAll('modus-wc-tree-item');
for (const item of items) {
if (item.selected) return item;
}
return null;
};
const selectionInDataSection = () => {
const selected = getSelectedTreeItem();
if (!selected) return false;
if (selected.getAttribute('value') === 'data') return true;
return Boolean(
selected.closest('modus-wc-tree-item[value="data"] modus-wc-tree-menu')
);
};
const shouldOpenDataFlyoutOnCollapse = () =>
getSelectedTreeItem()?.getAttribute('value') === 'data';
const selectSubmenuParent = (dataItem) => {
dataItem.selected = true;
sideNav.querySelectorAll('modus-wc-tree-item').forEach((item) => {
if (item !== dataItem) {
item.selected = false;
}
});
};
const collapseSubmenuInline = (treeItem) => {
const submenu = treeItem.querySelector('.modus-wc-menu-dropdown');
const liElement = treeItem.querySelector(':scope > li');
if (!submenu || !liElement) return;
submenu.classList.remove('modus-wc-menu-dropdown-show');
liElement.classList.remove('modus-wc-menu-item-expanded');
liElement.classList.remove('modus-wc-menu-dropdown-show');
if ('isExpanded' in treeItem) {
treeItem.isExpanded = false;
}
};
const openDataSubmenuInline = (dataItem) => {
const submenu = dataItem.querySelector('.modus-wc-menu-dropdown');
const liElement = dataItem.querySelector(':scope > li');
if (
!submenu ||
!liElement ||
submenu.classList.contains('modus-wc-menu-dropdown-show')
) {
return;
}
submenu.classList.add('modus-wc-menu-dropdown-show');
liElement.classList.add('modus-wc-menu-item-expanded');
liElement.classList.add('modus-wc-menu-dropdown-show');
if ('isExpanded' in dataItem) {
dataItem.isExpanded = true;
}
};
const collapseTreeSubmenus = () => {
sideNav.querySelectorAll('modus-wc-tree-item').forEach((treeItem) => {
if (treeItem.hasSubmenu && typeof treeItem.collapseSubmenu === 'function') {
treeItem.collapseSubmenu();
}
});
};
const handleExpandedChangeConnect = (expanded) => {
hideFlyout();
const treeItems = sideNav.querySelectorAll('modus-wc-tree-item');
if (expanded) {
setDataFlyoutDisabled(true);
treeItems.forEach((treeItem) => {
if (treeItem.hasSubmenu) {
treeItem.blockExpand = false;
}
});
if (selectionInDataSection()) {
const dataItem = sideNav.querySelector('modus-wc-tree-item[value="data"]');
if (dataItem) {
setTimeout(() => openDataSubmenuInline(dataItem), 0);
}
}
} else {
suppressNextMenuOpen = true;
setDataFlyoutDisabled(false);
treeItems.forEach((treeItem) => {
if (treeItem.hasSubmenu) {
treeItem.blockExpand = true;
collapseSubmenuInline(treeItem);
if (typeof treeItem.collapseSubmenu === 'function') {
treeItem.collapseSubmenu();
}
}
});
if (shouldOpenDataFlyoutOnCollapse()) {
collapseFlyoutTimer = setTimeout(() => {
collapseFlyoutTimer = null;
openDataFlyout();
}, SIDE_NAV_COLLAPSE_MS);
}
}
};
const wireConnectSideNavDemo = () => {
initConnectNavbar();
initDataFlyoutDisabled();
document
.getElementById('connect-navbar')
?.addEventListener('mainMenuOpenChange', (e) => {
if (suppressNextMenuOpen && e.detail) {
suppressNextMenuOpen = false;
return;
}
suppressNextMenuOpen = false;
sideNav.expanded = e.detail;
});
sideNav.addEventListener('expandedChange', (e) => {
const connectTheme = isConnectSideNavTheme();
if (!expandedChangeReady) {
expandedChangeReady = true;
if (connectTheme) {
setDataFlyoutDisabled(Boolean(e.detail));
if (!e.detail) {
handleExpandedChangeConnect(false);
}
} else if (!e.detail) {
suppressNextMenuOpen = true;
collapseTreeSubmenus();
}
return;
}
if (!connectTheme) {
if (!e.detail) {
suppressNextMenuOpen = true;
collapseTreeSubmenus();
}
return;
}
handleExpandedChangeConnect(e.detail);
});
if (!expandedChangeReady) {
expandedChangeReady = true;
if (isConnectSideNavTheme()) {
setDataFlyoutDisabled(Boolean(sideNav.expanded));
if (!sideNav.expanded) {
handleExpandedChangeConnect(false);
}
}
}
dataTreeItem?.addEventListener('itemSelect', (e) => {
if (e.target !== dataTreeItem) return;
if (sideNav.expanded) {
selectSubmenuParent(dataTreeItem);
return;
}
if (isConnectSideNavTheme()) {
openDataFlyout(true);
}
});
sideNav.addEventListener('itemSelect', (e) => {
const treeItem = e.target.closest('modus-wc-tree-item');
if (!treeItem) return;
const value = treeItem.getAttribute('value');
if (value === 'data' && sideNav.expanded) {
const dataItem = sideNav.querySelector(
'modus-wc-tree-item[value="data"]'
);
if (dataItem) {
selectSubmenuParent(dataItem);
}
}
if (
isConnectSideNavTheme() &&
!sideNav.expanded &&
treeItem.querySelector('modus-wc-tree-menu')
) {
openDataFlyout(true);
}
});
dataFlyoutDropdown?.addEventListener('itemSelect', (e) => {
if (!isConnectSideNavTheme()) return;
const value = e.detail?.value;
if (!value) return;
hideFlyout();
const realItem = sideNav.querySelector(
\`modus-wc-tree-item[value="\${value}"]\`
);
realItem
?.querySelector(':scope > li > .modus-wc-menu-item-interactive')
?.click();
dataFlyoutDropdown
?.querySelector('modus-wc-button .modus-wc-btn')
?.blur();
setTimeout(() => hideFlyout(), 0);
});
sideNav.querySelectorAll('modus-wc-dropdown-menu').forEach((dropdown) => {
if (dropdown.id === 'data-flyout-dropdown') return;
dropdown.addEventListener('itemSelect', () => {
dropdown.menuVisible = false;
});
});
};
Promise.all([
customElements.whenDefined('modus-wc-navbar'),
customElements.whenDefined('modus-wc-side-navigation'),
]).then(wireConnectSideNavDemo);
</script>
`;