UNPKG

ecfr-navigator

Version:

A lightweight, reusable Vue 3 component with Pinia integration for navigating hierarchical eCFR-style content in existing Vue applications.

1,246 lines 640 kB
import { defineStore, createPinia } from "pinia"; import { createElementBlock, openBlock, normalizeStyle, normalizeClass, createElementVNode, createCommentVNode, withModifiers, Fragment, createTextVNode, toDisplayString, renderList, resolveComponent, createBlock, createVNode, withDirectives, vModelSelect, withKeys, vModelText, Transition, withCtx, renderSlot, resolveDynamicComponent, ref, computed, vShow, mergeProps, reactive, watch, createStaticVNode, Teleport, onMounted, onUnmounted, provide, inject, readonly as readonly$2 } from "vue"; import { v4 } from "uuid"; const useECFRStore = defineStore("ecfr", { state: () => ({ rootItems: [], currentPath: [], currentItem: null, expandedItems: /* @__PURE__ */ new Set(), metadataRegistry: /* @__PURE__ */ new Map(), // Store for injected metadata metadataProcessors: /* @__PURE__ */ new Map() // Functions to process specific metadata types }), getters: { breadcrumbPath() { return this.currentPath; }, isItemExpanded() { return (itemId) => this.expandedItems.has(itemId); }, /** * Get metadata for an item * @param {string} itemId - ID of the item * @param {string} [metadataType] - Optional type of metadata to retrieve * @returns {Object|null} The metadata object or null if not found */ getItemMetadata() { return (itemId, metadataType) => { if (!this.metadataRegistry.has(itemId)) { return null; } const itemMetadata = this.metadataRegistry.get(itemId); if (metadataType && typeof itemMetadata === "object") { return itemMetadata[metadataType] || null; } return itemMetadata; }; }, /** * Get the metadata for the current item * @param {string} [metadataType] - Optional type of metadata to retrieve * @returns {Object|null} The metadata object or null if not found */ currentItemMetadata() { return (metadataType) => { if (!this.currentItem || !this.currentItem.id) { return null; } return this.getItemMetadata(this.currentItem.id, metadataType); }; }, /** * Get all registered metadata processors * @returns {Map} Map of metadata processors */ allMetadataProcessors() { return this.metadataProcessors; } }, actions: { setRootItems(items) { this.rootItems = items; if (items.length > 0 && !this.currentItem) { this.selectItem(items[0], 0); } }, selectItem(item, index, parentPath = []) { const newPath = [...parentPath, { item, index }]; this.currentPath = newPath; this.currentItem = item; if (item.children && item.children.length > 0) { this.expandItem(item.id); } }, navigateToPath(path) { if (path.length > 0) { this.currentPath = path; this.currentItem = path[path.length - 1].item; } }, expandItem(itemId) { this.expandedItems.add(itemId); }, collapseItem(itemId) { this.expandedItems.delete(itemId); }, toggleItemExpansion(itemId) { if (this.expandedItems.has(itemId)) { this.expandedItems.delete(itemId); } else { this.expandedItems.add(itemId); } }, expandAll() { const addAllItemIds = (items) => { for (const item of items) { this.expandedItems.add(item.id); if (item.children) { addAllItemIds(item.children); } } }; addAllItemIds(this.rootItems); }, collapseAll() { this.expandedItems.clear(); }, /** * Register a metadata processor function for a specific metadata type * @param {string} metadataType - Type identifier for the metadata * @param {Function} processorFn - Function to process this type of metadata */ registerMetadataProcessor(metadataType, processorFn) { if (typeof processorFn !== "function") { console.error(`Metadata processor for '${metadataType}' must be a function`); return; } this.metadataProcessors.set(metadataType, processorFn); }, /** * Remove a metadata processor * @param {string} metadataType - Type identifier for the metadata to remove */ unregisterMetadataProcessor(metadataType) { this.metadataProcessors.delete(metadataType); }, /** * Set metadata for an item * @param {string} itemId - ID of the item to attach metadata to * @param {Object} metadata - Metadata object to attach * @param {string} [metadataType] - Optional type identifier if adding a specific type */ setItemMetadata(itemId, metadata, metadataType) { if (!itemId) { console.error("Cannot set metadata: Item ID is required"); return; } if (!this.metadataRegistry.has(itemId)) { this.metadataRegistry.set(itemId, {}); } const itemMetadata = this.metadataRegistry.get(itemId); if (metadataType) { itemMetadata[metadataType] = metadata; } else if (typeof metadata === "object") { Object.assign(itemMetadata, metadata); } else { console.error("Invalid metadata format: must be an object"); } }, /** * Process the metadata for an item with registered processor functions * @param {string} itemId - ID of the item to process metadata for * @param {string} [metadataType] - Optional type of metadata to process * @returns {any} The processed metadata or null if processing failed */ processItemMetadata(itemId, metadataType) { const metadata = this.getItemMetadata(itemId, metadataType); if (!metadata) return null; if (!metadataType) { const result = {}; for (const [type, data] of Object.entries(metadata)) { if (this.metadataProcessors.has(type)) { const processor = this.metadataProcessors.get(type); result[type] = processor(data, itemId); } else { result[type] = data; } } return result; } if (this.metadataProcessors.has(metadataType)) { const processor = this.metadataProcessors.get(metadataType); return processor(metadata, itemId); } return metadata; }, /** * Remove metadata from an item * @param {string} itemId - ID of the item to remove metadata from * @param {string} [metadataType] - Optional type of metadata to remove (if not specified, removes all) */ removeItemMetadata(itemId, metadataType) { if (!this.metadataRegistry.has(itemId)) { return; } if (!metadataType) { this.metadataRegistry.delete(itemId); return; } const itemMetadata = this.metadataRegistry.get(itemId); if (itemMetadata && itemMetadata[metadataType]) { delete itemMetadata[metadataType]; } } } }); const _export_sfc = (sfc, props) => { const target = sfc.__vccOpts || sfc; for (const [key, val] of props) { target[key] = val; } return target; }; const _sfc_main$H = { name: "ECFRBreadcrumb", props: { /** * The current navigation path */ path: { type: Array, required: true }, /** * Whether to use dark mode */ darkMode: { type: Boolean, default: false }, /** * Configuration options for the breadcrumb */ options: { type: Object, default: () => ({}) } }, computed: { /** * Default options object * @returns {Object} Default options */ defaultOptions() { return { rootLabel: "Home", hideRootLink: false, truncate: false, truncateLabels: false, maxVisible: 3, visibleItems: 2, highlightLast: true, maxItemWidth: "12rem", separatorType: "icon", // 'icon' or 'text' separator: "/", style: "clean", // 'standard', 'compact', 'button', 'boxed', 'clean' type: "wrap", // 'wrap' or 'scrollable' useIcons: false, textSize: "", bgColor: "", maxWidth: "" }; }, /** * The merged options (default + provided) * @returns {Object} Merged options */ mergedOptions() { return { ...this.defaultOptions, ...this.options }; }, /** * Visible last items when using truncation * @returns {Array} Items to show at the end of truncated breadcrumbs */ visibleLastItems() { if (!this.path.length) return []; const maxVisible = this.mergedOptions.visibleItems || 2; const startIdx = Math.max(this.path.length - maxVisible, 1); return this.path.slice(startIdx).map((pathItem, idx) => ({ ...pathItem, index: startIdx + idx })); } }, methods: { /** * Format the breadcrumb label based on item type and metadata * @param {Object} item - The item to format * @returns {string} The formatted label */ formatBreadcrumbLabel(item) { if (!item) return ""; if (this.mergedOptions.labelFormat === "numbered" && item.type && item.number) { return `${item.type.charAt(0).toUpperCase() + item.type.slice(1)} ${item.number}${item.title ? ": " + item.title : ""}`; } else if (this.mergedOptions.labelFormat === "title-only") { return item.title || ""; } else if (this.mergedOptions.labelFormat === "number-only" && item.number) { return item.number; } else if (this.mergedOptions.labelFormat === "type-number" && item.type && item.number) { return `${item.type.charAt(0).toUpperCase() + item.type.slice(1)} ${item.number}`; } else if (item.type && item.number) { return `${item.type.charAt(0).toUpperCase() + item.type.slice(1)} ${item.number}${item.title ? ": " + item.title : ""}`; } return item.title || ""; }, /** * Check if an item is the last item in the path * @param {number} index - Index to check * @returns {boolean} True if it's the last item */ isLastItem(index) { return index === this.path.length - 1; }, /** * Check if an item is the last visible item in truncated view * @param {number} index - Index to check * @returns {boolean} True if it's the last visible item */ isLastVisibleItem(index) { return index === this.visibleLastItems.length - 1; }, /** * Navigate to the root level */ navigateToRoot() { this.$emit("navigate", null); }, /** * Navigate to a specific index in the path * @param {number} index - The index to navigate to */ navigateToIndex(index) { if (this.path && this.path[index]) { this.$emit("navigate", this.path[index]); } }, /** * Check if we should show a prefix for this item * @param {Object} item - The item to check * @returns {boolean} Whether to show a prefix */ showPrefix(item) { return item && item.type && item.type !== "root"; }, /** * Get the prefix for an item based on its type * @param {Object} item - The item to get a prefix for * @returns {string} The item prefix */ getItemPrefix(item) { if (!item || !item.type) return ""; if (item.citationPrefix) { return item.citationPrefix; } const typeMap = { title: "Title", part: "Part", section: "Section", subpart: "Subpart", chapter: "Chapter", paragraph: "Para.", subparagraph: "Subpara." }; return typeMap[item.type] || item.type.charAt(0).toUpperCase() + item.type.slice(1); } } }; const _hoisted_1$w = { key: 0, class: "inline-flex items-center shrink-0" }; const _hoisted_2$r = ["title"]; const _hoisted_3$q = { key: 0, class: "w-4 h-4", fill: "currentColor", viewBox: "0 0 20 20" }; const _hoisted_4$k = { class: "flex items-center" }; const _hoisted_5$j = { class: "mx-1 flex-shrink-0", "aria-hidden": "true" }; const _hoisted_6$g = ["onClick", "title"]; const _hoisted_7$e = { class: "shrink-0" }; const _hoisted_8$e = { class: "flex items-center" }; const _hoisted_9$d = { class: "mx-1 flex-shrink-0", "aria-hidden": "true" }; const _hoisted_10$c = ["title"]; const _hoisted_11$c = { class: "shrink-0" }; const _hoisted_12$c = { class: "flex items-center" }; const _hoisted_13$b = { class: "mx-1 flex-shrink-0", "aria-hidden": "true" }; const _hoisted_14$b = { class: "flex items-center" }; const _hoisted_15$a = { class: "mx-1 flex-shrink-0", "aria-hidden": "true" }; const _hoisted_16$a = ["onClick", "title"]; function _sfc_render$H(_ctx, _cache, $props, $setup, $data, $options) { return openBlock(), createElementBlock("nav", { class: normalizeClass(["flex text-sm overflow-x-auto", [ $props.darkMode ? "text-gray-300" : "text-gray-600", $props.options.style === "compact" ? "py-1 px-2" : "py-2 px-3", $props.options.style === "clean" ? "bg-white border-b border-gray-200" : "", $props.options.style === "boxed" ? "rounded-md shadow-sm border bg-opacity-50" : "", $props.darkMode && $props.options.style === "boxed" ? "border-gray-700 bg-gray-800" : "", !$props.darkMode && $props.options.style === "boxed" ? "border-gray-200 bg-gray-50" : "", $props.options.bgColor ? $props.options.bgColor : "", $props.options.textSize ? $props.options.textSize : "" ]]), "aria-label": "Breadcrumb", style: normalizeStyle({ maxWidth: $props.options.maxWidth || "none" }) }, [ createElementVNode("ol", { class: normalizeClass(["inline-flex items-center min-w-full", [$props.options.type === "scrollable" ? "flex-nowrap" : "flex-wrap"]]) }, [ !$props.options.hideRootLink ? (openBlock(), createElementBlock("li", _hoisted_1$w, [ createElementVNode("a", { href: "#", class: normalizeClass(["transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 rounded whitespace-nowrap", [ $props.darkMode ? "hover:text-blue-300" : "hover:text-blue-600", $props.options.style === "button" ? "px-2 py-1 rounded hover:bg-opacity-10 hover:bg-blue-500" : "hover:underline" ]]), onClick: _cache[0] || (_cache[0] = withModifiers((...args) => $options.navigateToRoot && $options.navigateToRoot(...args), ["prevent"])), title: $props.options.rootLabel }, [ $props.options.useIcons ? (openBlock(), createElementBlock("svg", _hoisted_3$q, _cache[2] || (_cache[2] = [ createElementVNode("path", { d: "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" }, null, -1) ]))) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [ createTextVNode(toDisplayString($props.options.rootLabel), 1) ], 64)) ], 10, _hoisted_2$r) ])) : createCommentVNode("", true), !$props.options.truncate || $props.path.length <= ($props.options.maxVisible || 3) ? (openBlock(true), createElementBlock(Fragment, { key: 1 }, renderList($props.path, (pathItem, index) => { return openBlock(), createElementBlock("li", { key: `breadcrumb-path-${index}-${pathItem.id || index}`, class: "shrink-0" }, [ createElementVNode("div", _hoisted_4$k, [ createElementVNode("div", _hoisted_5$j, [ $props.options.separatorType === "icon" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["w-4 h-4", [$props.darkMode ? "text-gray-400" : "text-gray-500"]]), fill: "currentColor", viewBox: "0 0 20 20" }, _cache[3] || (_cache[3] = [ createElementVNode("path", { "fill-rule": "evenodd", d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", "clip-rule": "evenodd" }, null, -1) ]), 2)) : $props.options.separatorType === "text" ? (openBlock(), createElementBlock("span", { key: 1, class: normalizeClass([$props.darkMode ? "text-gray-500" : "text-gray-400"]) }, toDisplayString($props.options.separator || "/"), 3)) : createCommentVNode("", true) ]), createElementVNode("a", { href: "#", class: normalizeClass(["transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 rounded truncate", [ $props.darkMode ? "hover:text-blue-300" : "hover:text-blue-600", $props.options.style === "button" ? "px-2 py-1 rounded hover:bg-opacity-10 hover:bg-blue-500" : "hover:underline", $options.isLastItem(index) && $props.options.highlightLast ? "font-medium" : "", $props.options.maxItemWidth ? "max-w-[" + $props.options.maxItemWidth + "]" : "" ]]), onClick: withModifiers(($event) => $options.navigateToIndex(index), ["prevent"]), title: $options.formatBreadcrumbLabel(pathItem) }, [ createElementVNode("span", { class: normalizeClass({ truncate: $props.options.truncateLabels }) }, toDisplayString($options.formatBreadcrumbLabel(pathItem)), 3) ], 10, _hoisted_6$g) ]) ]); }), 128)) : (openBlock(), createElementBlock(Fragment, { key: 2 }, [ createElementVNode("li", _hoisted_7$e, [ createElementVNode("div", _hoisted_8$e, [ createElementVNode("div", _hoisted_9$d, [ $props.options.separatorType === "icon" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["w-4 h-4", [$props.darkMode ? "text-gray-400" : "text-gray-500"]]), fill: "currentColor", viewBox: "0 0 20 20" }, _cache[4] || (_cache[4] = [ createElementVNode("path", { "fill-rule": "evenodd", d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", "clip-rule": "evenodd" }, null, -1) ]), 2)) : $props.options.separatorType === "text" ? (openBlock(), createElementBlock("span", { key: 1, class: normalizeClass([$props.darkMode ? "text-gray-500" : "text-gray-400"]) }, toDisplayString($props.options.separator || "/"), 3)) : createCommentVNode("", true) ]), createElementVNode("a", { href: "#", class: normalizeClass(["transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 rounded truncate", [ $props.darkMode ? "hover:text-blue-300" : "hover:text-blue-600", $props.options.style === "button" ? "px-2 py-1 rounded hover:bg-opacity-10 hover:bg-blue-500" : "hover:underline" ]]), onClick: _cache[1] || (_cache[1] = withModifiers(($event) => $options.navigateToIndex(0), ["prevent"])), title: $options.formatBreadcrumbLabel($props.path[0]) }, [ createElementVNode("span", { class: normalizeClass({ truncate: $props.options.truncateLabels }) }, toDisplayString($options.formatBreadcrumbLabel($props.path[0])), 3) ], 10, _hoisted_10$c) ]) ]), createElementVNode("li", _hoisted_11$c, [ createElementVNode("div", _hoisted_12$c, [ createElementVNode("div", _hoisted_13$b, [ $props.options.separatorType === "icon" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["w-4 h-4", [$props.darkMode ? "text-gray-400" : "text-gray-500"]]), fill: "currentColor", viewBox: "0 0 20 20" }, _cache[5] || (_cache[5] = [ createElementVNode("path", { "fill-rule": "evenodd", d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", "clip-rule": "evenodd" }, null, -1) ]), 2)) : $props.options.separatorType === "text" ? (openBlock(), createElementBlock("span", { key: 1, class: normalizeClass([$props.darkMode ? "text-gray-500" : "text-gray-400"]) }, toDisplayString($props.options.separator || "/"), 3)) : createCommentVNode("", true) ]), createElementVNode("span", { class: normalizeClass(["px-2 py-1", [$props.darkMode ? "text-gray-400" : "text-gray-500"]]) }, " ... ", 2) ]) ]), (openBlock(true), createElementBlock(Fragment, null, renderList($options.visibleLastItems, (pathItem, idx) => { return openBlock(), createElementBlock("li", { key: `breadcrumb-last-${idx}-${pathItem.id || idx}`, class: "shrink-0" }, [ createElementVNode("div", _hoisted_14$b, [ createElementVNode("div", _hoisted_15$a, [ $props.options.separatorType === "icon" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["w-4 h-4", [$props.darkMode ? "text-gray-400" : "text-gray-500"]]), fill: "currentColor", viewBox: "0 0 20 20" }, _cache[6] || (_cache[6] = [ createElementVNode("path", { "fill-rule": "evenodd", d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", "clip-rule": "evenodd" }, null, -1) ]), 2)) : $props.options.separatorType === "text" ? (openBlock(), createElementBlock("span", { key: 1, class: normalizeClass([$props.darkMode ? "text-gray-500" : "text-gray-400"]) }, toDisplayString($props.options.separator || "/"), 3)) : createCommentVNode("", true) ]), createElementVNode("a", { href: "#", class: normalizeClass(["transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 rounded truncate", [ $props.darkMode ? "hover:text-blue-300" : "hover:text-blue-600", $props.options.style === "button" ? "px-2 py-1 rounded hover:bg-opacity-10 hover:bg-blue-500" : "hover:underline", $options.isLastVisibleItem(idx) && $props.options.highlightLast ? "font-medium" : "", $props.options.maxItemWidth ? "max-w-[" + $props.options.maxItemWidth + "]" : "" ]]), onClick: withModifiers(($event) => $options.navigateToIndex(pathItem.index), ["prevent"]), title: $options.formatBreadcrumbLabel(pathItem) }, [ createElementVNode("span", { class: normalizeClass({ truncate: $props.options.truncateLabels }) }, toDisplayString($options.formatBreadcrumbLabel(pathItem)), 3) ], 10, _hoisted_16$a) ]) ]); }), 128)) ], 64)) ], 2) ], 6); } const ECFRBreadcrumb = /* @__PURE__ */ _export_sfc(_sfc_main$H, [["render", _sfc_render$H]]); const _sfc_main$G = { name: "ECFRSection", props: { /** * The item to display */ item: { type: Object, required: true }, /** * The index of the item in its parent's children array */ index: { type: Number, required: true }, /** * The nesting level of the item */ level: { type: Number, required: true }, /** * The path to the parent item */ parentPath: { type: Array, default: () => [] }, /** * Whether to use dark mode */ darkMode: { type: Boolean, default: false }, /** * Configuration options for the section */ options: { type: Object, default: () => ({}) } }, data() { return { ecfrStore: useECFRStore() }; }, computed: { /** * Default options object to use as fallback * @returns {Object} Default options */ defaultOptions() { return { display: { showTypeIcon: true, showItemNumbers: true, indentItems: true, hideEmptyItems: false, viewMode: "standard", // 'compact', 'standard', or 'detailed' showDescription: false, // Show content excerpt in detailed mode showMetadataBadges: false, // Show badges for items with metadata itemSpacing: "medium", // 'tight', 'medium', 'loose' maxTitleLength: null // Truncate titles longer than this }, navigation: { preserveState: true, scrollToSelected: true, autoExpandParents: true, autoCollapseOthers: false } }; }, /** * Merged options (defaults + provided options) * @returns {Object} Merged options */ mergedOptions() { return { ...this.defaultOptions, ...this.options, display: { ...this.defaultOptions.display, ...this.options.display || {} }, navigation: { ...this.defaultOptions.navigation, ...this.options.navigation || {} } }; }, /** * Whether the item has children * @returns {boolean} True if the item has children */ hasChildren() { return this.item.children && this.item.children.length > 0; }, /** * Whether the item should be hidden based on configuration * @returns {boolean} True if the item should be hidden */ shouldHideItem() { return this.mergedOptions.display.hideEmptyItems && !this.hasChildren && !this.item.content; }, /** * Whether the item is expanded * @returns {boolean} True if the item is expanded */ isExpanded() { return this.ecfrStore.isItemExpanded(this.item.id); }, /** * The current path to this item * @returns {Array} The current path */ currentPath() { return [...this.parentPath, { item: this.item, index: this.index }]; }, /** * Whether the item is selected * @returns {boolean} True if the item is selected */ isSelected() { var _a; return ((_a = this.ecfrStore.currentItem) == null ? void 0 : _a.id) === this.item.id; }, /** * CSS classes based on view mode * @returns {string} CSS classes for view mode */ viewModeClasses() { const mode = this.mergedOptions.display.viewMode; if (mode === "compact") { return "compact-mode py-0.5"; } else if (mode === "detailed") { return "detailed-mode py-2"; } else if (mode === "styleless") { return "styleless-mode"; } return "standard-mode py-1"; }, /** * CSS classes for item spacing * @returns {string} CSS classes for spacing */ spacingClasses() { const spacing = this.mergedOptions.display.itemSpacing; if (spacing === "tight") { return "mb-0.5"; } else if (spacing === "loose") { return "mb-2"; } return "mb-1"; }, /** * Whether we're in compact mode * @returns {boolean} True if in compact mode */ isCompactMode() { return this.mergedOptions.display.viewMode === "compact"; }, /** * Whether we're in detailed mode * @returns {boolean} True if in detailed mode */ isDetailedMode() { return this.mergedOptions.display.viewMode === "detailed"; }, /** * Process the title for display, handling truncation * @returns {string} The processed title for display */ displayTitle() { const maxLength = this.mergedOptions.display.maxTitleLength; if (!maxLength || !this.item.title || this.item.title.length <= maxLength) { return this.item.title; } return this.item.title.substring(0, maxLength) + "..."; }, /** * Whether the item has metadata * @returns {boolean} True if the item has metadata */ hasMetadata() { const metadata = this.ecfrStore.getItemMetadata(this.item.id); return !!metadata; }, /** * Get metadata types for this item * @returns {string[]} Array of metadata type names */ metadataTypes() { const metadata = this.ecfrStore.getItemMetadata(this.item.id); if (!metadata) return []; return Object.keys(metadata); }, /** * Content preview for detailed mode * @returns {string} Preview of content with HTML removed */ contentPreview() { if (!this.item.content) return ""; const textContent = this.item.content.replace(/<[^>]*>/g, ""); const maxLength = 150; if (textContent.length <= maxLength) return textContent; return textContent.substring(0, maxLength) + "..."; } }, methods: { /** * Toggle the expansion state of the item * @param {Event} event - The click event */ toggleExpand(event) { event.stopPropagation(); this.ecfrStore.toggleItemExpansion(this.item.id); }, /** * Handle selection of the item */ handleSelect() { this.ecfrStore.selectItem(this.item, this.index, this.parentPath); if (this.hasChildren && !this.isExpanded) { this.ecfrStore.expandItem(this.item.id); } if (this.mergedOptions.navigation.autoCollapseOthers && this.hasChildren) { this.collapseOthers(this.item.id); } if (this.mergedOptions.navigation.scrollToSelected) { this.$nextTick(() => { const selectedElement = document.querySelector(".ecfr-section .bg-blue-100, .ecfr-section .bg-blue-900"); if (selectedElement) { selectedElement.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }); } }, /** * Collapse all other items at the same level * @param {string} exceptId - ID of the item to keep expanded */ collapseOthers(exceptId) { if (this.level === 0 && this.item.id !== exceptId) { this.ecfrStore.collapseItem(this.item.id); return; } const siblings = this.parentPath.length > 0 ? this.parentPath[this.parentPath.length - 1].item.children : this.ecfrStore.rootItems; if (siblings) { siblings.forEach((sibling) => { if (sibling.id !== exceptId) { this.ecfrStore.collapseItem(sibling.id); } }); } } } }; const _hoisted_1$v = { key: 1, class: "mr-2 h-5 w-5 flex-shrink-0" }; const _hoisted_2$q = { class: "flex-1" }; const _hoisted_3$p = { class: "flex items-baseline" }; const _hoisted_4$j = { key: 1, class: "mr-2" }; const _hoisted_5$i = { key: 1, class: "flex items-start" }; const _hoisted_6$f = { class: "flex-1" }; const _hoisted_7$d = { class: "flex items-baseline flex-wrap" }; const _hoisted_8$d = { key: 1, class: "mr-2" }; const _hoisted_9$c = { key: 2, class: "ml-2 flex flex-wrap gap-1" }; const _hoisted_10$b = { key: 1, class: "text-sm mt-2 text-gray-600 dark:text-gray-400 max-w-prose" }; const _hoisted_11$b = ["innerHTML"]; const _hoisted_12$b = { key: 1, class: "pt-1" }; function _sfc_render$G(_ctx, _cache, $props, $setup, $data, $options) { const _component_ECFRSection = resolveComponent("ECFRSection", true); return !$options.shouldHideItem ? (openBlock(), createElementBlock("div", { key: 0, class: normalizeClass(["ecfr-section", [ // Common classes $props.darkMode ? "border-gray-600" : "border-gray-200", // Indentation $props.level > 0 && $options.mergedOptions.display.indentItems ? `pl-${Math.min($props.level * 4, 12)}` : "", !$options.mergedOptions.display.indentItems ? "" : "border-l-2", // View mode specific classes $options.viewModeClasses, // Spacing $options.spacingClasses ]]) }, [ createElementVNode("div", { class: normalizeClass([ // Base styling unless in styleless mode $options.mergedOptions.display.viewMode !== "styleless" ? "flex items-start cursor-pointer p-2 hover:bg-opacity-10 transition-colors duration-200 rounded" : "flex items-start cursor-pointer", // Selection highlight unless in styleless mode $options.mergedOptions.display.viewMode !== "styleless" && $options.isSelected ? $props.darkMode ? "bg-blue-900 bg-opacity-20" : "bg-blue-100" : "", // Group class for items with children $options.hasChildren ? "group" : "", // Hover effect unless in styleless mode $options.mergedOptions.display.viewMode !== "styleless" ? $props.darkMode ? "hover:bg-gray-700" : "hover:bg-gray-100" : "" ]), onClick: _cache[1] || (_cache[1] = (...args) => $options.handleSelect && $options.handleSelect(...args)) }, [ $options.hasChildren ? (openBlock(), createElementBlock("div", { key: 0, onClick: _cache[0] || (_cache[0] = withModifiers((...args) => $options.toggleExpand && $options.toggleExpand(...args), ["stop"])), class: normalizeClass(["mr-2 h-5 w-5 flex-shrink-0 mt-0.5 transition-transform duration-200", [$options.isExpanded ? "transform rotate-90" : ""]]) }, [ (openBlock(), createElementBlock("svg", { viewBox: "0 0 20 20", fill: "currentColor", class: normalizeClass([[$props.darkMode ? "text-gray-400" : "text-gray-500"], "h-5 w-5 group-hover:text-blue-500 transition-colors duration-200"]) }, _cache[2] || (_cache[2] = [ createElementVNode("path", { "fill-rule": "evenodd", d: "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", "clip-rule": "evenodd" }, null, -1) ]), 2)) ], 2)) : (openBlock(), createElementBlock("div", _hoisted_1$v)), createElementVNode("div", _hoisted_2$q, [ !$options.isDetailedMode ? (openBlock(), createElementBlock(Fragment, { key: 0 }, [ createElementVNode("div", _hoisted_3$p, [ $props.item.number && $options.mergedOptions.display.showItemNumbers ? (openBlock(), createElementBlock("span", { key: 0, class: normalizeClass(["font-semibold mr-2", [ $props.darkMode ? "text-blue-300" : "text-blue-600", $options.isCompactMode ? "text-xs" : "" ]]) }, toDisplayString($props.item.citationPrefix || $props.item.type) + " " + toDisplayString($props.item.number), 3)) : createCommentVNode("", true), $options.mergedOptions.display.showTypeIcon && $props.item.type && !$options.isCompactMode ? (openBlock(), createElementBlock("span", _hoisted_4$j, [ $props.item.type === "title" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-blue-300" : "text-blue-600"]]), viewBox: "0 0 24 24" }, _cache[3] || (_cache[3] = [ createElementVNode("path", { fill: "currentColor", d: "M5 4v3h5.5v12h3V7H19V4z" }, null, -1) ]), 2)) : $props.item.type === "part" ? (openBlock(), createElementBlock("svg", { key: 1, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-green-300" : "text-green-600"]]), viewBox: "0 0 24 24" }, _cache[4] || (_cache[4] = [ createElementVNode("path", { fill: "currentColor", d: "M14,10H2V12H14V10M14,6H2V8H14V6M2,16H10V14H2V16M21.5,11.5L23,13L16,20L11.5,15.5L13,14L16,17L21.5,11.5Z" }, null, -1) ]), 2)) : $props.item.type === "section" ? (openBlock(), createElementBlock("svg", { key: 2, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-amber-300" : "text-amber-600"]]), viewBox: "0 0 24 24" }, _cache[5] || (_cache[5] = [ createElementVNode("path", { fill: "currentColor", d: "M3,4H7V8H3V4M9,5V7H21V5H9M3,10H7V14H3V10M9,11V13H21V11H9M3,16H7V20H3V16M9,17V19H21V17H9" }, null, -1) ]), 2)) : createCommentVNode("", true) ])) : createCommentVNode("", true), createElementVNode("span", { class: normalizeClass(["font-medium truncate", [ $props.darkMode ? "text-gray-200" : "text-gray-800", $options.isCompactMode ? "text-sm" : "" ]]) }, toDisplayString($options.displayTitle), 3), $options.hasMetadata && $options.mergedOptions.display.showMetadataBadges ? (openBlock(), createElementBlock("span", { key: 2, class: normalizeClass(["ml-2 px-1.5 py-0.5 rounded text-xs", [$props.darkMode ? "bg-blue-900 text-blue-200" : "bg-blue-100 text-blue-700"]]) }, toDisplayString($options.metadataTypes.length), 3)) : createCommentVNode("", true) ]), $props.item.subtitle && !$options.isCompactMode ? (openBlock(), createElementBlock("div", { key: 0, class: normalizeClass(["text-sm mt-1", [$props.darkMode ? "text-gray-400" : "text-gray-600"]]) }, toDisplayString($props.item.subtitle), 3)) : createCommentVNode("", true) ], 64)) : (openBlock(), createElementBlock("div", _hoisted_5$i, [ createElementVNode("div", _hoisted_6$f, [ createElementVNode("div", _hoisted_7$d, [ $props.item.number && $options.mergedOptions.display.showItemNumbers ? (openBlock(), createElementBlock("span", { key: 0, class: normalizeClass(["font-semibold mr-2", [$props.darkMode ? "text-blue-300" : "text-blue-600"]]) }, toDisplayString($props.item.citationPrefix || $props.item.type) + " " + toDisplayString($props.item.number), 3)) : createCommentVNode("", true), $options.mergedOptions.display.showTypeIcon && $props.item.type ? (openBlock(), createElementBlock("span", _hoisted_8$d, [ $props.item.type === "title" ? (openBlock(), createElementBlock("svg", { key: 0, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-blue-300" : "text-blue-600"]]), viewBox: "0 0 24 24" }, _cache[6] || (_cache[6] = [ createElementVNode("path", { fill: "currentColor", d: "M5 4v3h5.5v12h3V7H19V4z" }, null, -1) ]), 2)) : $props.item.type === "part" ? (openBlock(), createElementBlock("svg", { key: 1, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-green-300" : "text-green-600"]]), viewBox: "0 0 24 24" }, _cache[7] || (_cache[7] = [ createElementVNode("path", { fill: "currentColor", d: "M14,10H2V12H14V10M14,6H2V8H14V6M2,16H10V14H2V16M21.5,11.5L23,13L16,20L11.5,15.5L13,14L16,17L21.5,11.5Z" }, null, -1) ]), 2)) : $props.item.type === "section" ? (openBlock(), createElementBlock("svg", { key: 2, class: normalizeClass(["h-4 w-4 inline-block", [$props.darkMode ? "text-amber-300" : "text-amber-600"]]), viewBox: "0 0 24 24" }, _cache[8] || (_cache[8] = [ createElementVNode("path", { fill: "currentColor", d: "M3,4H7V8H3V4M9,5V7H21V5H9M3,10H7V14H3V10M9,11V13H21V11H9M3,16H7V20H3V16M9,17V19H21V17H9" }, null, -1) ]), 2)) : createCommentVNode("", true) ])) : createCommentVNode("", true), createElementVNode("span", { class: normalizeClass(["font-medium", [$props.darkMode ? "text-gray-200" : "text-gray-800"]]) }, toDisplayString($props.item.title), 3), $options.hasMetadata && $options.mergedOptions.display.showMetadataBadges ? (openBlock(), createElementBlock("div", _hoisted_9$c, [ (openBlock(true), createElementBlock(Fragment, null, renderList($options.metadataTypes, (type) => { return openBlock(), createElementBlock("span", { key: type, class: normalizeClass(["px-1.5 py-0.5 rounded text-xs", [$props.darkMode ? "bg-blue-900 text-blue-200" : "bg-blue-100 text-blue-700"]]) }, toDisplayString(type), 3); }), 128)) ])) : createCommentVNode("", true) ]), $props.item.subtitle ? (openBlock(), createElementBlock("div", { key: 0, class: normalizeClass(["text-sm mt-1", [$props.darkMode ? "text-gray-400" : "text-gray-600"]]) }, toDisplayString($props.item.subtitle), 3)) : createCommentVNode("", true), $options.mergedOptions.display.showDescription && $options.contentPreview ? (openBlock(), createElementBlock("div", _hoisted_10$b, toDisplayString($options.contentPreview), 1)) : createCommentVNode("", true) ]) ])) ]) ], 2), $props.item.content && ($options.isExpanded || $options.isSelected) ? (openBlock(), createElementBlock("div", { key: 0, class: normalizeClass(["px-3 py-2 my-1 rounded", [$props.darkMode ? "bg-gray-800 text-gray-300" : "bg-gray-50 text-gray-700"]]) }, [ createElementVNode("div", { class: "prose max-w-none text-sm", innerHTML: $props.item.content }, null, 8, _hoisted_11$b) ], 2)) : createCommentVNode("", true), $options.hasChildren && $options.isExpanded ? (openBlock(), createElementBlock("div", _hoisted_12$b, [ (openBlock(true), createElementBlock(Fragment, null, renderList($props.item.children, (child, idx) => { return openBlock(), createBlock(_component_ECFRSection, { key: child.id, item: child, index: idx, level: $props.level + 1, "parent-path": $options.currentPath, "dark-mode": $props.darkMode }, null, 8, ["item", "index", "level", "parent-path", "dark-mode"]); }), 128)) ])) : createCommentVNode("", true) ], 2)) : createCommentVNode("", true); } const ECFRSection = /* @__PURE__ */ _export_sfc(_sfc_main$G, [["render", _sfc_render$G]]); function isArray(value) { return !Array.isArray ? getTag(value) === "[object Array]" : Array.isArray(value); } function baseToString(value) { if (typeof value == "string") { return value; } let result = value + ""; return result == "0" && 1 / value == -Infinity ? "-0" : result; } function toString(value) { return value == null ? "" : baseToString(value); } function isString(value) { return typeof value === "string"; } function isNumber(value) { return typeof value === "number"; } function isBoolean(value) { return value === true || value === false || isObjectLike(value) && getTag(value) == "[object Boolean]"; } function isObject(value) { return typeof value === "object"; } function isObjectLike(value) { return isObject(value) && value !== null; } function isDefined(value) { return value !== void 0 && value !== null; } function isBlank(value) { return !value.trim().length; } function getTag(value) { return value == null ? value === void 0 ? "[object Undefined]" : "[object Null]" : Object.prototype.toString.call(value); } const INCORRECT_INDEX_TYPE = "Incorrect 'index' type"; const LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY = (key) => `Invalid value for key ${key}`; const PATTERN_LENGTH_TOO_LARGE = (max) => `Pattern length exceeds max of ${max}.`; const MISSING_KEY_PROPERTY = (name) => `Missing ${name} property in key`; const INVALID_KEY_WEIGHT_VALUE = (key) => `Property 'weight' in key '${key}' must be a positive integer`; const hasOwn = Object.prototype.hasOwnProperty; class KeyStore { constructor(keys) { this._keys = []; this._keyMap = {}; let totalWeight = 0; keys.forEach((key) => { let obj = createKey(key); this._keys.push(obj); this._keyMap[obj.id] = obj; totalWeight += obj.weight; }); this._keys.forEach((key) => { key.weight /= totalWeight; }); } get(keyId) { return this._keyMap[keyId]; } keys() { return this._keys; } toJSON() { return JSON.stringify(this._keys); } } function createKey(key) { let path = null; let id = null; let src = null; let weight = 1; let getFn = null; if (isString(key) || isArray(key)) { src = key; path = createKeyPath(key); id = createKeyId(key); } else { if (!hasOwn.call(key, "name")) { throw new Error(MISSING_KEY_PROPERTY("name")); } const name = key.name; src = name; if (hasOwn.call(key, "weight")) { weight = key.weight; if (weight <= 0) { throw new Error(INVALID_KEY_WEIGHT_VALUE(name)); } } path = createKeyPath(name); id = createKeyId(name); getFn = key.getFn; } return { path, id, weight, src, getFn }; } function createKeyPath(key) { return isArray(key) ? key : key.split("."); } function createKeyId(key) { return isArray(key) ? key.join(".") : key; } function get(obj, path) { let list = []; let arr = false; const deepGet = (obj2, path2, index) => { if (!isDefined(obj2)) { return; } if (!path2[index]) { list.push(obj2); } else { let key = path2[index]; const value = obj2[key]; if (!isDefined(value)) { return; } if (index === path2.length - 1 && (isString(value) || isNumber(value) || isBoolean(value))) { list.push(toString(value)); } else if (isArray(value)) { arr = true; for (let i = 0, len = value.length; i < len; i += 1) { deepGet(value[i], path2, index + 1); } } else if (path2.length) { deepGet(value, path2, index + 1); } } }; deepGet(obj, isString(path) ? path.split(".") : path, 0); return arr ? list : list[0]; } const MatchOptions = { // Whether the matches should be included in the result set. When `true`, each record in the result // set will include the indices of the matched characters. // These can consequently be used for highlighting purposes. includeMatches: false, // When `true`, the matching function will continue to the end of a search pattern even if // a perfect match has already been located in the string. findAllMatches: false, // Minimum number of characters that must be matched before a result is considered a match minMatchCharLength: 1 }; const BasicOptions = { // When `true`, the algorithm continues searching to the end of the input even if a perfect // match is found before the end of the same input. isCaseSensitive: false, // When `true`, the algorithm will ignore diacritics (accents) in comparisons ignoreDiacritics: false, // When true, the matching function will continue to the end of a search pattern even if includeScore: false, // List of properties that will be searched. This also supports nested properties. keys: [], // Whether to sort the result list, by score shouldSort: true, // Default sort function: sort by ascending score, ascending index sortFn: (a, b) => a.score === b.score ? a.idx < b.idx ? -1 : 1 : a.score < b.score ? -1 : 1 }; const FuzzyOptions = { // Approximately where in the text is the pattern expected to be found? location: 0, // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match // (of both letters and location), a threshold of '1.0' would match anything. threshold: 0.6, // Determines how close the match must be to the fuzzy location (specified above). // An exact letter match which is 'distance' characters away from the fuzzy location // would score as a complete mismatch. A distance of '0' requires the match be at // the exact location specified, a threshold of '1000' would require a perfect match // to be within 800 characters of the fuzzy location to be found using a 0.8 threshold. distance: 100 }; const AdvancedOptions = { // When `true`, it enables the use of unix-like search commands useExtendedSearch: false, // The get function to use when fetching an object's properties. // The default will search nested paths *ie foo.bar.baz* getFn: get, // When `true`, search will ignore `location` and `distance`, so it won't matter // where in the string the pattern appears. // More info: https://fusejs.io/concepts/scoring-theory.html#fuzziness-score ignoreLocation: false, // When `true`, the calculation for the relevance score (used for sorting) will // ignore the field-length norm. // More info: https://fusejs.io/concepts/scoring-theory.html#field-length-norm ignoreFieldNorm: false, // The wei