feat: add modal overlay for type definitions

Implement a modal popup system for viewing type definitions without leaving
the current context, with browser-like back/forward navigation.

Modal UI Components:
- Full-screen overlay with centered modal dialog
- Header with title, close button, and navigation controls
- Scrollable content area for type definition
- Back (←) and forward (→) navigation buttons
- Visual disabled state for navigation at history bounds

Navigation Features:
- Click any type reference in root schema to open in modal
- Click type references within modal to navigate deeper
- Keyboard shortcuts: Alt+← (back), Alt+→ (forward), Escape (close)
- History tracking with array and index pointer
- History resets on modal close

JavaScript Implementation:
- `openTypeModal(typeName, addToHistory)`: Open/navigate to type
- `showType(typeName)`: Clone and display type definition content
- `navigateHistory(direction)`: Handle back/forward navigation
- `closeModal()`: Close modal and reset state
- `updateNavButtons()`: Enable/disable nav buttons based on position

CSS Styling (102 lines added):
- `.modal-overlay`: Fixed positioning with semi-transparent background
- `.modal`: Centered container with max dimensions and shadow
- `.modal-header`: Flex layout for nav, title, and close button
- `.modal-nav-btn`: Styled navigation buttons with disabled state
- `.modal-content`: Scrollable content with padding

Identifier Updates:
- Add modal-related IDs and classes to identifiers.rs
- New ID: `modal-overlay`
- New classes: modal, modal-active, modal-header, modal-title, modal-close,
  modal-content, modal-nav, modal-nav-btn, disabled
This commit is contained in:
LGUG2Z committed 2025-12-22 09:31:29 -08:00
1 parent f32d94d929
commit c6835ddbb2
7 files changed
+312 -8

No files matched your search

+134
View File
@@ -1686,4 +1686,138 @@ mod tests {
broken.iter().take(20).collect::<Vec<_>>()
);
}
// Test: Modal has navigation buttons
#[test]
fn test_modal_has_navigation_buttons() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"field": { "type": "string" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should have modal navigation container
assert!(
html.contains("modal-nav"),
"Should have modal-nav container"
);
// Should have back and forward buttons
assert!(
html.contains("data-dir=\"back\""),
"Should have back button"
);
assert!(
html.contains("data-dir=\"forward\""),
"Should have forward button"
);
// Should have navigation button class
assert!(
html.contains("modal-nav-btn"),
"Should have nav button class"
);
}
// Test: Modal nav buttons start disabled
#[test]
fn test_modal_nav_buttons_start_disabled() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"field": { "type": "string" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Both nav buttons should start with disabled class
assert!(
html.contains("modal-nav-btn disabled"),
"Nav buttons should start disabled"
);
}
// Test: Modal has keyboard navigation
#[test]
fn test_modal_has_keyboard_navigation() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"field": { "type": "string" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should have Alt+Arrow keyboard handling
assert!(html.contains("altKey"), "Should handle Alt key modifier");
assert!(
html.contains("ArrowLeft"),
"Should handle ArrowLeft for back"
);
assert!(
html.contains("ArrowRight"),
"Should handle ArrowRight for forward"
);
}
// Test: Modal has history management
#[test]
fn test_modal_has_history_management() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"field": { "type": "string" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// Should have history array and index
assert!(
html.contains("history=[]"),
"Should initialize history array"
);
assert!(
html.contains("historyIndex=-1"),
"Should initialize history index"
);
// Should have navigate function
assert!(
html.contains("navigateHistory"),
"Should have navigateHistory function"
);
}
// Test: Modal history cleared on close
#[test]
fn test_modal_history_cleared_on_close() {
let generator = Generator::new().unwrap();
let schema = make_schema(json!({
"title": "Test",
"type": "object",
"properties": {
"field": { "type": "string" }
}
}));
let options = Options::default();
let html = generator.generate(&schema, &options).unwrap();
// closeModal function should reset history
// The function contains: history=[];historyIndex=-1;
assert!(
html.contains("history=[]") && html.contains("historyIndex=-1"),
"closeModal should reset history state"
);
}
}
+34
View File
@@ -15,6 +15,7 @@ pub struct HtmlIds {
pub sidebar_toggle: &'static str,
pub root_schema: &'static str,
pub definitions: &'static str,
pub modal_overlay: &'static str,
}
/// All static HTML IDs
@@ -25,6 +26,7 @@ pub const IDS: HtmlIds = HtmlIds {
sidebar_toggle: "sidebar-toggle",
root_schema: "root-schema",
definitions: "definitions",
modal_overlay: "modal-overlay",
};
/// CSS class names organized by category
@@ -123,6 +125,18 @@ pub struct CssClasses {
pub usages: &'static str,
pub usage_section: &'static str,
pub usage_list: &'static str,
// Modal
pub modal_overlay: &'static str,
pub modal: &'static str,
pub modal_active: &'static str,
pub modal_header: &'static str,
pub modal_title: &'static str,
pub modal_close: &'static str,
pub modal_content: &'static str,
pub modal_nav: &'static str,
pub modal_nav_btn: &'static str,
pub modal_nav_disabled: &'static str,
}
/// All CSS class names
@@ -220,6 +234,18 @@ pub const CSS: CssClasses = CssClasses {
usages: "usages",
usage_section: "usage-section",
usage_list: "usage-list",
// Modal
modal_overlay: "modal-overlay",
modal: "modal",
modal_active: "modal-active",
modal_header: "modal-header",
modal_title: "modal-title",
modal_close: "modal-close",
modal_content: "modal-content",
modal_nav: "modal-nav",
modal_nav_btn: "modal-nav-btn",
modal_nav_disabled: "disabled",
};
/// Type-specific badge class suffixes (used with type-badge prefix)
@@ -281,4 +307,12 @@ mod tests {
assert!(TYPE_CLASSES.integer.starts_with("type-"));
assert!(TYPE_CLASSES.array.starts_with("type-"));
}
#[test]
fn test_modal_nav_classes_are_valid() {
// Modal navigation CSS classes should not contain spaces
assert!(!CSS.modal_nav.contains(' '));
assert!(!CSS.modal_nav_btn.contains(' '));
assert!(!CSS.modal_nav_disabled.contains(' '));
}
}
+2
View File
@@ -1,3 +1,5 @@
#![warn(clippy::all)]
//! # schemars-docgen
//!
//! Generate HTML documentation from `schemars::Schema` objects.
+2
View File
@@ -1,3 +1,5 @@
#![warn(clippy::all)]
use clap::Parser;
use schemars_docgen::Options;
use schemars_docgen::generate_html;
+7 -7
View File
@@ -39,13 +39,13 @@ fn add_wrapper_display_names(schema: &mut Value) {
let display_name = name.trim_end_matches(|c: char| c.is_ascii_digit());
// Only add _display_name if it differs from the original name
if display_name != name {
if let Some(obj) = def.as_object_mut() {
obj.insert(
"_display_name".to_string(),
Value::String(display_name.to_string()),
);
}
if display_name != name
&& let Some(obj) = def.as_object_mut()
{
obj.insert(
"_display_name".to_string(),
Value::String(display_name.to_string()),
);
}
}
}
+31 -1
View File
@@ -151,8 +151,38 @@
{%- endif -%}
</main>
</div>
<div class="{{ css.modal_overlay }}" id="{{ ids.modal_overlay }}">
<div class="{{ css.modal }}">
<div class="{{ css.modal_header }}">
<div class="{{ css.modal_nav }}">
<button
class="{{ css.modal_nav_btn }} {{ css.modal_nav_disabled }}"
aria-label="Go back"
data-dir="back"
>
&larr;
</button>
<button
class="{{ css.modal_nav_btn }} {{ css.modal_nav_disabled }}"
aria-label="Go forward"
data-dir="forward"
>
&rarr;
</button>
</div>
<h3 class="{{ css.modal_title }}"></h3>
<button
class="{{ css.modal_close }}"
aria-label="Close modal"
>
&times;
</button>
</div>
<div class="{{ css.modal_content }}"></div>
</div>
</div>
<script>
(function(){const searchInput=document.getElementById('{{ ids.search }}');const searchResults=document.getElementById('{{ ids.search_results }}');const searchIndex={{ search_index | json_encode() | safe }};searchInput.addEventListener('input',function(e){const query=e.target.value.toLowerCase().trim();if(!query){searchResults.innerHTML='';searchResults.classList.remove('{{ css.search_results_active }}');return;}const matches=searchIndex.filter(item=>item.name.toLowerCase().includes(query)||item.path.toLowerCase().includes(query)).slice(0,10);if(matches.length===0){searchResults.innerHTML='<div class="{{ css.search_result_item }} {{ css.no_results }}">No results found</div>';}else{searchResults.innerHTML=matches.map(item=>`<a href="#${item.anchor}" class="{{ css.search_result_item }}"><span class="{{ css.result_name }}">${item.name}</span><span class="{{ css.result_path }}">${item.path}</span><span class="{{ css.result_type }}">${item.entry_type}</span></a>`).join('');}searchResults.classList.add('{{ css.search_results_active }}');});document.addEventListener('click',function(e){if(!searchInput.contains(e.target)&&!searchResults.contains(e.target)){searchResults.classList.remove('{{ css.search_results_active }}');}});searchInput.addEventListener('keydown',function(e){if(e.key==='Escape'){searchResults.classList.remove('{{ css.search_results_active }}');searchInput.blur();}});const sidebarToggle=document.getElementById('{{ ids.sidebar_toggle }}');const sidebar=document.getElementById('{{ ids.sidebar }}');if(sidebarToggle&&sidebar){sidebarToggle.addEventListener('click',function(){sidebar.classList.toggle('{{ css.sidebar_open }}');});}document.querySelectorAll('a[href^="#"]').forEach(anchor=>{anchor.addEventListener('click',function(e){const targetId=this.getAttribute('href').slice(1);const target=document.getElementById(targetId);if(target){target.classList.add('{{ css.highlight }}');setTimeout(()=>target.classList.remove('{{ css.highlight }}'),2000);}if(sidebar){sidebar.classList.remove('{{ css.sidebar_open }}');}searchResults.classList.remove('{{ css.search_results_active }}');});});})();
(function(){const searchInput=document.getElementById('{{ ids.search }}');const searchResults=document.getElementById('{{ ids.search_results }}');const searchIndex={{ search_index | json_encode() | safe }};searchInput.addEventListener('input',function(e){const query=e.target.value.toLowerCase().trim();if(!query){searchResults.innerHTML='';searchResults.classList.remove('{{ css.search_results_active }}');return;}const matches=searchIndex.filter(item=>item.name.toLowerCase().includes(query)||item.path.toLowerCase().includes(query)).slice(0,10);if(matches.length===0){searchResults.innerHTML='<div class="{{ css.search_result_item }} {{ css.no_results }}">No results found</div>';}else{searchResults.innerHTML=matches.map(item=>`<a href="#${item.anchor}" class="{{ css.search_result_item }}"><span class="{{ css.result_name }}">${item.name}</span><span class="{{ css.result_path }}">${item.path}</span><span class="{{ css.result_type }}">${item.entry_type}</span></a>`).join('');}searchResults.classList.add('{{ css.search_results_active }}');});document.addEventListener('click',function(e){if(!searchInput.contains(e.target)&&!searchResults.contains(e.target)){searchResults.classList.remove('{{ css.search_results_active }}');}});searchInput.addEventListener('keydown',function(e){if(e.key==='Escape'){searchResults.classList.remove('{{ css.search_results_active }}');searchInput.blur();}});const sidebarToggle=document.getElementById('{{ ids.sidebar_toggle }}');const sidebar=document.getElementById('{{ ids.sidebar }}');if(sidebarToggle&&sidebar){sidebarToggle.addEventListener('click',function(){sidebar.classList.toggle('{{ css.sidebar_open }}');});}document.querySelectorAll('a[href^="#"]').forEach(anchor=>{anchor.addEventListener('click',function(e){const targetId=this.getAttribute('href').slice(1);const target=document.getElementById(targetId);if(target){target.classList.add('{{ css.highlight }}');setTimeout(()=>target.classList.remove('{{ css.highlight }}'),2000);}if(sidebar){sidebar.classList.remove('{{ css.sidebar_open }}');}searchResults.classList.remove('{{ css.search_results_active }}');});});const modalOverlay=document.getElementById('{{ ids.modal_overlay }}');const modalTitle=modalOverlay.querySelector('.{{ css.modal_title }}');const modalContent=modalOverlay.querySelector('.{{ css.modal_content }}');const modalClose=modalOverlay.querySelector('.{{ css.modal_close }}');const modalNavBtns=modalOverlay.querySelectorAll('.{{ css.modal_nav_btn }}');const configSection=document.getElementById('{{ ids.root_schema }}');let history=[];let historyIndex=-1;function updateNavButtons(){modalNavBtns.forEach(btn=>{const dir=btn.dataset.dir;if(dir==='back'){btn.classList.toggle('{{ css.modal_nav_disabled }}',historyIndex<=0);}else if(dir==='forward'){btn.classList.toggle('{{ css.modal_nav_disabled }}',historyIndex>=history.length-1);}});}function showType(typeName){const typeId=typeName.toLowerCase();const typeDef=document.getElementById(typeId);if(!typeDef)return false;const clone=typeDef.cloneNode(true);clone.removeAttribute('id');const usages=clone.querySelector('.{{ css.usages }}');if(usages)usages.remove();const titleEl=clone.querySelector('.{{ css.definition_name }}');modalTitle.textContent=titleEl?titleEl.textContent:typeName;if(titleEl)titleEl.remove();modalContent.innerHTML='';modalContent.appendChild(clone);return true;}function openTypeModal(typeName,addToHistory=true){if(!showType(typeName))return;if(addToHistory){history=history.slice(0,historyIndex+1);history.push(typeName);historyIndex=history.length-1;}updateNavButtons();modalOverlay.classList.add('{{ css.modal_active }}');document.body.style.overflow='hidden';}function navigateHistory(direction){if(direction==='back'&&historyIndex>0){historyIndex--;showType(history[historyIndex]);updateNavButtons();}else if(direction==='forward'&&historyIndex<history.length-1){historyIndex++;showType(history[historyIndex]);updateNavButtons();}}function closeModal(){modalOverlay.classList.remove('{{ css.modal_active }}');document.body.style.overflow='';history=[];historyIndex=-1;updateNavButtons();}modalNavBtns.forEach(btn=>{btn.addEventListener('click',function(){if(!this.classList.contains('{{ css.modal_nav_disabled }}')){navigateHistory(this.dataset.dir);}});});configSection.addEventListener('click',function(e){const typeRef=e.target.closest('.{{ css.type_ref }}');if(typeRef){e.preventDefault();const href=typeRef.getAttribute('href');const typeName=href.slice(1);openTypeModal(typeName);}});modalContent.addEventListener('click',function(e){const typeRef=e.target.closest('.{{ css.type_ref }}');if(typeRef){e.preventDefault();const href=typeRef.getAttribute('href');const typeName=href.slice(1);openTypeModal(typeName);}});modalOverlay.addEventListener('click',function(e){if(e.target===modalOverlay)closeModal();});modalClose.addEventListener('click',closeModal);document.addEventListener('keydown',function(e){if(!modalOverlay.classList.contains('{{ css.modal_active }}'))rLine truncated
</script>
</body>
</html>
+102
View File
@@ -943,6 +943,108 @@ body {
}
}
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 2rem;
}
.modal-overlay.modal-active {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 8px;
max-width: 800px;
max-height: 80vh;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.modal-nav {
display: flex;
gap: 0.25rem;
margin-right: 0.75rem;
}
.modal-nav-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
color: var(--text-primary);
font-size: 1rem;
line-height: 1;
transition:
background-color 0.15s,
opacity 0.15s;
}
.modal-nav-btn:hover:not(.disabled) {
background-color: var(--bg-tertiary);
}
.modal-nav-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
flex: 1;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
/* Dark mode modal */
@media (prefers-color-scheme: dark) {
.modal-overlay {
background: rgba(0, 0, 0, 0.7);
}
}
/* Print styles */
@media print {
.header {