UNPKG

wj-elements

Version:

WebJET Elements is a modern set of user interface tools harnessing the power of web components designed to simplify web application development.

725 lines (724 loc) 26.9 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import WJElement from "./wje-element.js"; const styles = "/*\n[ WJ Breadcrumbs ]\n*/\n\n:host {\n --wje-breadcrumbs-breakpoint-sm: 576px;\n --wje-breadcrumbs-breakpoint-md: 768px;\n --wje-breadcrumbs-breakpoint-lg: 992px;\n --wje-breadcrumbs-breakpoint-xl: 1200px;\n --wje-breadcrumbs-breakpoint-2xl: 1450px;\n --wje-breadcrumbs-breakpoint-xxl: 1450px;\n\n display: flex;\n flex-wrap: nowrap;\n align-items: center;\n min-width: 0;\n overflow: hidden;\n}\n"; const MANAGED_ITEM_ATTRIBUTE = "data-wje-breadcrumbs-item"; const MOBILE_COLLAPSED_VARIANTS = /* @__PURE__ */ new Set(["text", "back", "parent-title", "menu-title", "sheet"]); const _Breadcrumbs = class _Breadcrumbs extends WJElement { /** * Breadcrumbs constructor method. * @class */ constructor() { super(); /** * Class name for the Breadcrumbs element. * @type {string} */ __publicField(this, "className", "Breadcrumbs"); this.last = false; this._isCollapsedByBreakpoint = null; this._items = []; this._itemRecords = []; this._hasItemsValue = false; this.handleManagedItemClick = (e) => this.dispatchManagedItemClick(e); } /** * Sets data-driven breadcrumb items. * @param {Array|string|null|undefined} value Breadcrumb items or a JSON string. */ set items(value) { const records = this.normalizeItems(value); this._hasItemsValue = true; this._items = records.map((record) => record.item); this._itemRecords = records; this.syncItems(records); } /** * Gets data-driven breadcrumb items. * @returns {Array} */ get items() { return this._items; } /** * Set variant attribute for the Breadcrumbs element. * @param value */ set variant(value) { this.setAttribute("variant", value); } /** * Get variant attribute for the Breadcrumbs element. * @returns {string} */ get variant() { return this.getAttribute("variant") || "button"; } /** * Get the collapsed indicator variant. * @param {string} value Collapsed indicator variant. */ set collapsedVariant(value) { if (value) this.setAttribute("collapsed-variant", value); else this.removeAttribute("collapsed-variant"); } /** * Get the collapsed indicator variant. * @returns {string} */ get collapsedVariant() { return this.getAttribute("collapsed-variant") || this.variant; } /** * Gets the collapsed indicator variant as a normalized token. * @returns {string} */ get normalizedCollapsedVariant() { return String(this.collapsedVariant || "").trim().toLowerCase(); } /** * Sets the collapse breakpoint token or value. * @param {string} value Breakpoint token or CSS size. */ set breakpoint(value) { if (value) this.setAttribute("breakpoint", value); else this.removeAttribute("breakpoint"); } /** * Gets the collapse breakpoint token or value. * @returns {string} */ get breakpoint() { return this.getAttribute("breakpoint") || ""; } /** * Sets the collapse behavior used below the configured breakpoint. * @param {string} value Collapse behavior. */ set breakpointCollapse(value) { if (value) this.setAttribute("breakpoint-collapse", value); else this.removeAttribute("breakpoint-collapse"); } /** * Gets the collapse behavior used below the configured breakpoint. * @returns {string} */ get breakpointCollapse() { return (this.getAttribute("breakpoint-collapse") || "items").toLowerCase(); } /** * Sets the icon used by the default breakpoint menu trigger. * @param {string} value Icon name. */ set breakpointCollapseIcon(value) { if (value) this.setAttribute("breakpoint-collapse-icon", value); else this.removeAttribute("breakpoint-collapse-icon"); } /** * Gets the icon used by the default breakpoint menu trigger. * @returns {string} */ get breakpointCollapseIcon() { return this.getAttribute("breakpoint-collapse-icon") || ""; } /** * Get items before collapse attribute. * @param {string} value */ set maxItems(value) { this.setAttribute("max-items", value || 0); } /** * Get items before collapse attribute. * @returns {number} */ get maxItems() { return +this.getAttribute("max-items"); } /** * Get items before collapse attribute. * @param value */ set itemsBeforeCollapse(value) { this.setAttribute("items-before-collapse", value || 1); } /** * Get items before collapse attribute. * @returns {number} */ get itemsBeforeCollapse() { return +this.getAttribute("items-before-collapse") || 1; } /** * Get items after collapse attribute. * @param value */ set itemsAfterCollapse(value) { this.setAttribute("items-after-collapse", value || 1); } /** * Get items after collapse attribute. * @returns {number} */ get itemsAfterCollapse() { if (this.hasAttribute("items-after-collapse")) { return +this.getAttribute("items-after-collapse") || 1; } const derivedItemsAfterCollapse = this.maxItems - this.itemsBeforeCollapse - 1; return derivedItemsAfterCollapse > 0 ? derivedItemsAfterCollapse : 1; } /** * Get CSS stylesheet for the Breadcrumbs element. * @static * @returns {object} styles - The CSS styles */ static get cssStyleSheet() { return styles; } /** * Get observed attributes for the Breadcrumb element. * @static * @returns {Array<string>} - The observed attributes array for the Breadcrumb element. */ static get observedAttributes() { return [ "breakpoint", "breakpoint-collapse", "max-items", "items-before-collapse", "items-after-collapse", "collapsed-variant", "breakpoint-collapse-icon", "variant", "aria-label", "aria-labelledby" ]; } /** * Setup attributes for the Breadcrumbs element. */ setupAttributes() { this.isShadowRoot = "open"; this.syncAria(); this.upgradeProperty("items"); if (!this._hasItemsValue && this.hasAttribute("items")) { this.items = this.getAttribute("items"); } } /** * Handles attribute changes that affect light-DOM breadcrumb indicators. * @param {string} name Updated attribute. * @param {string|null} oldValue Previous value. * @param {string|null} newValue Next value. */ attributeChangedCallback(name, oldValue, newValue) { var _a; if (oldValue === newValue) return; (_a = super.attributeChangedCallback) == null ? void 0 : _a.call(this, name, oldValue, newValue); if (name === "aria-label" || name === "aria-labelledby") { this.syncAria(); } if (name === "collapsed-variant" || name === "variant" || name === "breakpoint-collapse" || name === "breakpoint-collapse-icon") { this.updateCollapse(); this.refreshCollapsedIndicators(); } } /** * Syncs host navigation semantics while preserving user-provided names. */ syncAria() { this.setAriaState({ role: "navigation" }); const label = this.getAttribute("aria-label"); const hasLabelledBy = this.hasAttribute("aria-labelledby"); if (!label && !hasLabelledBy) { this.setAriaState({ label: "Breadcrumb" }); } else if (hasLabelledBy && label === "Breadcrumb") { this.removeAttribute("aria-label"); } } /** * Draw method for the Breadcrumbs element. * @returns {object} fragment - The document fragment */ draw() { let fragment = document.createDocumentFragment(); let element = document.createElement("slot"); fragment.appendChild(element); this.defaultSlot = element; return fragment; } /** * Updates the breadcrumb elements after they are drawn on the page. * It manages attributes on breadcrumb items and handles the logic for collapsing breadcrumbs * if the total exceeds the specified maximum items. * @returns {void} This method does not return a value. */ afterDraw() { var _a; this.onSlotChange = () => this.updateCollapse(); (_a = this.defaultSlot) == null ? void 0 : _a.addEventListener("slotchange", this.onSlotChange); this.addEventListener("click", this.handleManagedItemClick); this.handleResize = () => this.handleBreakpointResize(); if (this.getBreakpointWidth()) { window.addEventListener("resize", this.handleResize); } if (this._itemRecords.length > 0 && this.getManagedBreadcrumbs().length === 0) { this.syncItems(this._itemRecords); } this.updateCollapse(); } /** * Removes listeners after disconnect. */ afterDisconnect() { var _a; (_a = this.defaultSlot) == null ? void 0 : _a.removeEventListener("slotchange", this.onSlotChange); this.removeEventListener("click", this.handleManagedItemClick); window.removeEventListener("resize", this.handleResize); this._isCollapsedByBreakpoint = null; } /** * Upgrades properties set before the custom element definition was loaded. * @param {string} property Property name. */ upgradeProperty(property) { if (!Object.prototype.hasOwnProperty.call(this, property)) return; const value = this[property]; delete this[property]; this[property] = value; } /** * Normalizes incoming item data for keyed DOM diffing. * @param {Array|string|null|undefined} value Breadcrumb item data. * @returns {Array<object>} */ normalizeItems(value) { let items = value; if (typeof items === "string") { try { items = JSON.parse(items); } catch (e) { console.warn("Invalid JSON passed to wje-breadcrumbs.items", e); items = []; } } if (items === null || items === void 0) return []; if (!Array.isArray(items)) { console.warn("wje-breadcrumbs.items expects an array."); return []; } const usedKeys = /* @__PURE__ */ new Set(); return items.map((item, index) => this.normalizeItem(item, index, usedKeys)); } /** * Normalizes a single item while preserving the original object for events. * @param {object|string|number} item Source value for a breadcrumb entry. * @param {number} index Position of the value in the current items array. * @param {Set<string>} usedKeys Keys that have already been assigned during this update. * @returns {object} */ normalizeItem(item, index, usedKeys) { const isObject = item !== null && typeof item === "object"; const source = isObject ? item : { id: index, label: item === null || item === void 0 ? "" : String(item) }; return { item: source, key: this.getItemKey(source, index, usedKeys), index, label: source.label === null || source.label === void 0 ? "" : String(source.label), href: source.href === null || source.href === void 0 ? null : String(source.href), icon: source.icon === null || source.icon === void 0 || source.icon === "" ? null : String(source.icon), title: source.title === null || source.title === void 0 ? null : String(source.title), disabled: source.disabled === true || source.disabled === "" || source.disabled === "true" || source.disabled === 1 || source.disabled === "1" }; } /** * Resolves a stable key for an item. * @param {object} item Normalized source object used to resolve the key. * @param {number} index Fallback position used when the item has no id. * @param {Set<string>} usedKeys Keys that have already been assigned during this update. * @returns {string} */ getItemKey(item, index, usedKeys) { const baseKey = item.id === null || item.id === void 0 ? String(index) : String(item.id); let key = baseKey; let suffix = 1; while (usedKeys.has(key)) { key = `${baseKey}:${suffix}`; suffix += 1; } usedKeys.add(key); return key; } /** * Incrementally synchronizes data-driven items into light DOM breadcrumbs. * @param {Array<object>} records Normalized records for the next breadcrumb trail. */ syncItems(records) { const managed = this.getManagedBreadcrumbs(); const managedByKey = new Map(managed.map((breadcrumb) => [breadcrumb.__wjeBreadcrumbKey || breadcrumb.getAttribute(MANAGED_ITEM_ATTRIBUTE), breadcrumb])); const nextKeys = new Set(records.map((record) => record.key)); managed.forEach((breadcrumb) => { const key = breadcrumb.__wjeBreadcrumbKey || breadcrumb.getAttribute(MANAGED_ITEM_ATTRIBUTE); if (!nextKeys.has(key)) { breadcrumb.remove(); } }); let referenceNode = this.firstChild; records.forEach((record, index) => { let breadcrumb = managedByKey.get(record.key); const signature = this.getItemStructureSignature(record); if (breadcrumb && breadcrumb.__wjeBreadcrumbSignature !== signature) { const previousBreadcrumb = breadcrumb; const replacement = this.createItemElement(record, index); breadcrumb.replaceWith(replacement); breadcrumb = replacement; if (referenceNode === previousBreadcrumb) referenceNode = breadcrumb; } else if (!breadcrumb) { breadcrumb = this.createItemElement(record, index); } else { this.updateItemElement(breadcrumb, record, index); } if (breadcrumb !== referenceNode) { this.insertBefore(breadcrumb, referenceNode); } referenceNode = breadcrumb.nextSibling; }); this.updateCollapse(); } /** * Creates a managed breadcrumb element. * @param {object} record Normalized data used to create the breadcrumb. * @param {number} index Position assigned to the created breadcrumb. * @returns {HTMLElement} */ createItemElement(record, index) { const breadcrumb = document.createElement("wje-breadcrumb"); this.updateItemElement(breadcrumb, record, index); return breadcrumb; } /** * Updates a managed breadcrumb element only where values changed. * @param {HTMLElement} breadcrumb Existing managed element to update. * @param {object} record Normalized data used for the next rendered state. * @param {number} index Position assigned to the managed breadcrumb. */ updateItemElement(breadcrumb, record, index) { breadcrumb.__wjeBreadcrumbManaged = true; breadcrumb.__wjeBreadcrumbItem = record.item; breadcrumb.__wjeBreadcrumbIndex = index; breadcrumb.__wjeBreadcrumbKey = record.key; breadcrumb.__wjeBreadcrumbSignature = this.getItemStructureSignature(record); if (breadcrumb.getAttribute(MANAGED_ITEM_ATTRIBUTE) !== record.key) { breadcrumb.setAttribute(MANAGED_ITEM_ATTRIBUTE, record.key); } this.syncItemAttribute(breadcrumb, "href", record.href); this.syncItemAttribute(breadcrumb, "title", record.title); this.syncItemAttribute(breadcrumb, "aria-label", record.label || null); this.syncBooleanItemAttribute(breadcrumb, "disabled", record.disabled); this.syncItemContent(breadcrumb, record); } /** * Synchronizes an attribute when its value changed. * @param {HTMLElement} element Element receiving the synchronized attribute. * @param {string} name Attribute to add, update, or remove. * @param {string|null|undefined} value Next serialized value for the attribute. */ syncItemAttribute(element, name, value) { if (value === null || value === void 0 || value === false) { if (element.hasAttribute(name)) element.removeAttribute(name); return; } const normalizedValue = String(value); if (element.getAttribute(name) !== normalizedValue) { element.setAttribute(name, normalizedValue); } } /** * Synchronizes a boolean attribute. * @param {HTMLElement} element Element receiving the boolean attribute. * @param {string} name Boolean attribute to toggle. * @param {boolean} isEnabled Whether the attribute should be present. */ syncBooleanItemAttribute(element, name, isEnabled) { if (isEnabled && !element.hasAttribute(name)) { element.setAttribute(name, ""); } if (!isEnabled && element.hasAttribute(name)) { element.removeAttribute(name); } } /** * Synchronizes slotted icon and text nodes for a managed breadcrumb. * @param {HTMLElement} breadcrumb Breadcrumb element. * @param {object} record Normalized item record. */ syncItemContent(breadcrumb, record) { let icon = breadcrumb.__wjeBreadcrumbIcon; if (record.icon) { if (!icon || icon.parentElement !== breadcrumb) { icon = document.createElement("wje-icon"); icon.setAttribute("slot", "start"); breadcrumb.insertBefore(icon, breadcrumb.firstChild); breadcrumb.__wjeBreadcrumbIcon = icon; } if (icon.getAttribute("name") !== record.icon) { icon.setAttribute("name", record.icon); } } else if (icon) { icon.remove(); breadcrumb.__wjeBreadcrumbIcon = null; } let labelNode = breadcrumb.__wjeBreadcrumbLabelNode; if (!labelNode || labelNode.parentNode !== breadcrumb) { labelNode = document.createTextNode(""); breadcrumb.appendChild(labelNode); breadcrumb.__wjeBreadcrumbLabelNode = labelNode; } if (labelNode.textContent !== record.label) { labelNode.textContent = record.label; } } /** * Returns a small signature for structural changes that need item replacement. * @param {object} record Normalized item record. * @returns {string} */ getItemStructureSignature(record) { return `icon:${record.icon ? "1" : "0"}`; } /** * Dispatches the data-driven item click event. * @param {MouseEvent} e Original click event. */ dispatchManagedItemClick(e) { var _a; const breadcrumb = this.getManagedBreadcrumbFromEvent(e); if (!breadcrumb) return; const item = breadcrumb.__wjeBreadcrumbItem; if ((item == null ? void 0 : item.disabled) || breadcrumb.hasAttribute("disabled")) { e.preventDefault(); e.stopPropagation(); (_a = e.stopImmediatePropagation) == null ? void 0 : _a.call(e); return; } const itemClickEvent = new CustomEvent("wje-breadcrumbs:item-click", { detail: { item, index: breadcrumb.__wjeBreadcrumbIndex, originalEvent: e }, bubbles: true, composed: true, cancelable: true }); const shouldContinue = this.dispatchEvent(itemClickEvent); if (!shouldContinue) { e.preventDefault(); } } /** * Finds the managed breadcrumb that originated an event. * @param {Event} e Event whose composed path should be inspected. * @returns {HTMLElement|null} */ getManagedBreadcrumbFromEvent(e) { const path = typeof e.composedPath === "function" ? e.composedPath() : []; return path.find((node) => { var _a; return (node == null ? void 0 : node.nodeType) === Node.ELEMENT_NODE && ((_a = node.tagName) == null ? void 0 : _a.toLowerCase()) === "wje-breadcrumb" && node.parentElement === this && node.hasAttribute(MANAGED_ITEM_ATTRIBUTE); }) || null; } /** * Reacts to viewport resize only when the breakpoint mode actually changes. * @returns {void} */ handleBreakpointResize() { if (!this.getBreakpointWidth()) return; const nextState = this.shouldApplyBreakpointCollapse(); if (this._isCollapsedByBreakpoint === nextState) return; this.updateCollapse(); } /** * Recalculates breadcrumb collapse state. * @returns {void} */ updateCollapse() { let breadcrumbs = this.getBreadcrumbs(); if (breadcrumbs.length === 0) return; this._isCollapsedByBreakpoint = this.shouldApplyBreakpointCollapse(); const shouldCollapseToMenu = this.isBreakpointMenuCollapseActive() && (breadcrumbs.length > 1 || this.usesMobileCollapsedVariant()); if (shouldCollapseToMenu) { this.applyMenuCollapse(breadcrumbs); return; } const effectiveItemsAfterCollapse = this.itemsAfterCollapse; const shouldUseItemCollapse = this.breakpointCollapse === "menu" || this._isCollapsedByBreakpoint; const shouldCollapse = shouldUseItemCollapse && this.maxItems > 0 && breadcrumbs.length > this.maxItems && this.itemsBeforeCollapse + effectiveItemsAfterCollapse + 1 <= this.maxItems; const lastIndex = breadcrumbs.length - 1; const indicatorIndex = shouldCollapse ? this.itemsBeforeCollapse : -1; const collapseStart = this.itemsBeforeCollapse; const collapseEnd = breadcrumbs.length - effectiveItemsAfterCollapse; breadcrumbs.forEach((breadcrumb, index) => { this.syncManagedAttribute(breadcrumb, "last", index === lastIndex); this.syncManagedAttribute(breadcrumb, "show-collapsed-indicator", index === indicatorIndex); this.syncManagedAttribute(breadcrumb, "mobile-collapsed-indicator", false); const isCollapsed = shouldCollapse && index >= collapseStart && index < collapseEnd; this.syncManagedAttribute(breadcrumb, "collapsed", isCollapsed); if (!isCollapsed && breadcrumb.classList.contains("collapsed")) { breadcrumb.classList.remove("collapsed"); } }); this.refreshCollapsedIndicators(); } /** * Collapses the whole breadcrumb trail into a single menu indicator. * @param {Array<Element>} breadcrumbs Breadcrumb items. */ applyMenuCollapse(breadcrumbs) { const lastIndex = breadcrumbs.length - 1; const usesMobileVariant = this.usesMobileCollapsedVariant(); breadcrumbs.forEach((breadcrumb, index) => { const isIndicator = index === 0; this.syncManagedAttribute(breadcrumb, "last", index === lastIndex); this.syncManagedAttribute(breadcrumb, "show-collapsed-indicator", isIndicator); this.syncManagedAttribute(breadcrumb, "collapsed", true); this.syncManagedAttribute(breadcrumb, "mobile-collapsed-indicator", isIndicator && usesMobileVariant); if (isIndicator) { breadcrumb.classList.remove("collapsed"); } }); this.refreshCollapsedIndicators(); } /** * Returns whether the active breakpoint mode should move the full trail into one menu. * @returns {boolean} */ isBreakpointMenuCollapseActive() { return Boolean( this.getBreakpointWidth() && this._isCollapsedByBreakpoint && this.breakpointCollapse === "menu" ); } /** * Returns whether the active collapsed variant is one of the mobile compact layouts. * @returns {boolean} */ usesMobileCollapsedVariant() { return MOBILE_COLLAPSED_VARIANTS.has(this.normalizedCollapsedVariant); } /** * Clears attributes/classes managed by the collapse algorithm. * @param {Array<Element>} breadcrumbs Breadcrumb items. */ resetCollapseState(breadcrumbs = this.getBreadcrumbs()) { breadcrumbs.forEach((breadcrumb) => { breadcrumb.removeAttribute("collapsed"); breadcrumb.removeAttribute("show-collapsed-indicator"); breadcrumb.removeAttribute("mobile-collapsed-indicator"); breadcrumb.removeAttribute("last"); breadcrumb.classList.remove("collapsed"); }); } /** * Applies a managed boolean attribute only when its value truly changes. * @param {Element} element Breadcrumb item whose responsive state is being synchronized. * @param {string} name Managed state flag that should be synchronized on the breadcrumb item. * @param {boolean} isEnabled Whether the attribute should be present. */ syncManagedAttribute(element, name, isEnabled) { if (!element) return; const hasAttribute = element.hasAttribute(name); if (isEnabled && !hasAttribute) { element.setAttribute(name, true); } if (!isEnabled && hasAttribute) { element.removeAttribute(name); } } /** * Redraws active collapsed indicators when parent-only rendering inputs change. */ refreshCollapsedIndicators() { this.getBreadcrumbs().filter((breadcrumb) => breadcrumb.hasAttribute("show-collapsed-indicator")).forEach((breadcrumb) => { var _a; return (_a = breadcrumb.refresh) == null ? void 0 : _a.call(breadcrumb); }); } /** * Returns whether collapse rules should currently be applied. * @returns {boolean} */ shouldApplyBreakpointCollapse() { const breakpointWidth = this.getBreakpointWidth(); if (!breakpointWidth) return true; return window.innerWidth < breakpointWidth; } /** * Resolves the configured breakpoint to a pixel width. * @returns {number|null} */ getBreakpointWidth() { if (!this.breakpoint) return null; const token = this.breakpoint.trim().toLowerCase(); const cssValue = getComputedStyle(this).getPropertyValue(`--wje-breadcrumbs-breakpoint-${token}`).trim(); const namedBreakpoint = _Breadcrumbs.BREAKPOINTS[token]; if (cssValue) { const cssNumber = parseFloat(cssValue); if (Number.isFinite(cssNumber)) return cssNumber; } if (Number.isFinite(namedBreakpoint)) { return namedBreakpoint; } const directNumber = parseFloat(token); return Number.isFinite(directNumber) ? directNumber : null; } /** * Returns the custom trigger element configured for breakpoint menu collapse. * @returns {Element|null} */ getBreakpointCollapseTrigger() { return Array.from(this.children).find((child) => { var _a; return ((_a = child.getAttribute) == null ? void 0 : _a.call(child, "slot")) === "breakpoint-collapse-trigger"; }) || null; } /** * Retrieves all breadcrumb elements within the current instance. * @returns {Array<Element>} An array of breadcrumb elements (`wje-breadcrumb`) found within the instance. Returns an empty array if no breadcrumbs are found. */ getBreadcrumbs() { return Array.from(this.querySelectorAll("wje-breadcrumb")) || []; } /** * Retrieves breadcrumbs managed by the items property. * @returns {Array<Element>} */ getManagedBreadcrumbs() { return Array.from(this.querySelectorAll(`wje-breadcrumb[${MANAGED_ITEM_ATTRIBUTE}]`)) || []; } /** * Retrieves all breadcrumb elements that have the 'collapsed' attribute. * @returns {Array<Element>} An array of DOM elements representing breadcrumbs with the 'collapsed' attribute. */ getBreadcrumbsCollapsed() { return Array.from(this.querySelectorAll("wje-breadcrumb[collapsed]")) || []; } }; __publicField(_Breadcrumbs, "BREAKPOINTS", { sm: 576, md: 768, lg: 992, xl: 1200, "2xl": 1450, xxl: 1450 }); let Breadcrumbs = _Breadcrumbs; Breadcrumbs.define("wje-breadcrumbs", Breadcrumbs); export { Breadcrumbs as default }; //# sourceMappingURL=wje-breadcrumbs.js.map