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
JavaScript
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