From c6835ddbb280a7bc2892dcf400615a2f7731e6f7 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sun, 21 Dec 2025 20:31:39 -0800 Subject: [PATCH] feat: add modal overlay for type definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/generator.rs | 134 ++++++++++++++++++++++++++++++++++++++++ src/identifiers.rs | 34 ++++++++++ src/lib.rs | 2 + src/main.rs | 2 + src/prepare.rs | 14 ++--- src/templates/base.html | 32 +++++++++- src/templates/style.css | 102 ++++++++++++++++++++++++++++++ 7 files changed, 312 insertions(+), 8 deletions(-) diff --git a/src/generator.rs b/src/generator.rs index 29a608fa..d28eec09 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1686,4 +1686,138 @@ mod tests { broken.iter().take(20).collect::>() ); } + + // 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" + ); + } } diff --git a/src/identifiers.rs b/src/identifiers.rs index a2c0a70b..543087b7 100644 --- a/src/identifiers.rs +++ b/src/identifiers.rs @@ -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(' ')); + } } diff --git a/src/lib.rs b/src/lib.rs index 6577e203..e4e25026 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![warn(clippy::all)] + //! # schemars-docgen //! //! Generate HTML documentation from `schemars::Schema` objects. diff --git a/src/main.rs b/src/main.rs index b22f99fe..b2484e22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![warn(clippy::all)] + use clap::Parser; use schemars_docgen::Options; use schemars_docgen::generate_html; diff --git a/src/prepare.rs b/src/prepare.rs index 52dc26aa..cd8da2ab 100644 --- a/src/prepare.rs +++ b/src/prepare.rs @@ -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()), + ); } } } diff --git a/src/templates/base.html b/src/templates/base.html index 5072bf06..b1180005 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -151,8 +151,38 @@ {%- endif -%} +
+
+
+
+ + +
+

+ +
+
+
+
diff --git a/src/templates/style.css b/src/templates/style.css index 56de70e1..b1485401 100644 --- a/src/templates/style.css +++ b/src/templates/style.css @@ -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 {