浏览代码

refactor(component): add spry-else-if and spry-else chain support

Improve conditional rendering to properly handle spry-if chains:
- Process conditional nodes one at a time to avoid nesting issues
- Implement spry-else-if and spry-else as chained conditionals
- Remove sibling nodes when a condition is satisfied to prevent duplicates
- Change element selection methods to return Enumerable<MarkupNode>
- Refactor transform_per_attributes to use doc.nodes for iteration

Update documentation with SSE continuation features, expression attributes,
and prepare_once() lifecycle method.
Billy Barrow 1 周之前
父节点
当前提交
d4f332bb35
共有 3 个文件被更改,包括 233 次插入33 次删除
  1. 0 1
      examples/ProgressExample.vala
  2. 184 1
      important-details.md
  3. 49 31
      src/Component.vala

+ 0 - 1
examples/ProgressExample.vala

@@ -152,7 +152,6 @@ class ProgressComponent : Component {
         status = "Task completed successfully!";
         yield continuation_context.send_fragment("progress", "progress-bar");
         yield continuation_context.send_fragment("status", "status");
-        yield continuation_context.send_string("log", "<div class=\"log-entry\">✓ All tasks completed!</div>");
     }
 }
 

+ 184 - 1
important-details.md

@@ -13,7 +13,7 @@
 - Centralizes data fetching logic in one place
 
 ```vala
-public override void prepare() throws Error {
+public override async void prepare() throws Error {
     var item = store.get_by_id(_item_id);
     if (item == null) return;
     
@@ -22,6 +22,18 @@ public override void prepare() throws Error {
 }
 ```
 
+### The `prepare_once()` Method
+- Called only once before the first `prepare()` call
+- Useful for one-time initialization that should not repeat on every render
+- Runs before `prepare()` in the same request lifecycle
+
+```vala
+public override async void prepare_once() throws Error {
+    // One-time setup, e.g., loading initial data
+    initial_data = yield fetch_initial_data();
+}
+```
+
 ### The `handle_action()` Method
 - Called when HTMX requests trigger an action
 - Modify state in stores, then let `prepare()` handle template updates
@@ -43,6 +55,106 @@ public async override void handle_action(string action) throws Error {
 }
 ```
 
+## Real-Time Updates with Continuations (SSE)
+
+### Overview
+
+The continuation feature allows a Component to send real-time updates to the client via Server-Sent Events (SSE). This is useful for:
+- Long-running task progress reporting
+- Real-time status updates
+- Live data streaming
+
+### The `continuation()` Method
+
+Override `continuation(ContinuationContext context)` to send SSE events:
+
+```vala
+public async override void continuation(ContinuationContext continuation_context) throws Error {
+    for (int i = 0; i <= 100; i += 10) {
+        percent = i;
+        status = @"Processing... $(i)%";
+        
+        // Send fragment updates to the client
+        yield continuation_context.send_fragment("progress", "progress-bar");
+        yield continuation_context.send_fragment("status", "status");
+        
+        Timeout.add(500, () => {
+            continuation.callback();
+            return false;
+        });
+        yield;
+    }
+    
+    status = "Complete!";
+    yield continuation_context.send_fragment("status", "status");
+}
+```
+
+### The `continuation_canceled()` Method
+
+Called when the client disconnects from the SSE stream:
+
+```vala
+public async override void continuation_canceled() throws Error {
+    // Clean up resources when client disconnects
+    cleanup_task();
+}
+```
+
+### ContinuationContext API
+
+| Method | Description |
+|--------|-------------|
+| `send_fragment(event_type, sid)` | Send a fragment (by `sid`) as an SSE event with the given event type |
+| `send_json(event_type, node)` | Send JSON data as an SSE event |
+| `send_string(event_type, data)` | Send raw string data as an SSE event |
+| `send_full_update(event_type)` | Send the entire component document |
+| `send_event(event)` | Send a custom `SseEvent` |
+
+### The `spry-continuation` Attribute
+
+Add `spry-continuation` to an element to enable SSE:
+
+```vala
+public override string markup { get {
+    return """
+    <div spry-continuation>
+        <div class="progress-bar" sid="progress-bar" sse-swap="progress">
+            0%
+        </div>
+        <div class="status" sid="status" sse-swap="status">
+            Initializing...
+        </div>
+    </div>
+    """;
+}}
+```
+
+The `spry-continuation` attribute is shorthand for:
+- `hx-ext="sse"` - Enable HTMX SSE extension
+- `sse-connect="(auto-generated-endpoint)"` - Connect to the SSE endpoint
+- `sse-close="_spry-close"` - Close event name
+
+### The `sse-swap` Attribute
+
+Use `sse-swap="eventname"` on child elements to specify which SSE event type should swap the content:
+
+```html
+<div sid="progress-bar" sse-swap="progress">...</div>
+<div sid="status" sse-swap="status">...</div>
+```
+
+When `continuation_context.send_fragment("progress", "progress-bar")` is called, the fragment with `sid="progress-bar"` is sent as an SSE event with type "progress", and HTMX swaps it into the element listening for that event type.
+
+### Required Scripts for SSE
+
+Include the HTMX SSE extension in your markup:
+
+```html
+<script spry-res="htmx.js"></script>
+<script spry-res="htmx-sse.js"></script>
+```
+
 ## HTMX Integration
 
 ### Declarative Attributes
@@ -130,6 +242,77 @@ var id = int.parse(id_str);
 
 **Note**: `hx-vals` is inherited by child elements, so set it on the parent div rather than individual buttons.
 
+## Declarative Expression Attributes
+
+### Expression Attributes (`*-expr`)
+
+Use `*-expr` attributes to dynamically set any attribute based on component properties:
+
+```vala
+class ProgressComponent : Component {
+    public int percent { get; set; }
+    
+    public override string markup { get {
+        return """
+        <div class="progress-bar" 
+             content-expr='format("%i%%", this.percent)' 
+             style-width-expr='format("%i%%", this.percent)'>
+            0%
+        </div>
+        """;
+    }}
+}
+```
+
+Expression attribute patterns:
+- `content-expr="expression"` - Set text/HTML content
+- `class-expr="expression"` - Set CSS classes
+- `style-expr="expression"` - Set inline styles (e.g., `style-width-expr`)
+- `any-attr-expr="expression"` - Set any attribute dynamically
+
+### Conditional Class Expressions
+
+For `class-*` attributes, boolean expressions add/remove the class:
+
+```html
+<div class-completed-expr="this.is_completed">Item</div>
+```
+
+If `this.is_completed` is `true`, the `completed` class is added.
+
+### Conditional Rendering with `spry-if`
+
+Use `spry-if`, `spry-else-if`, and `spry-else` for conditional rendering:
+
+```html
+<div spry-if="this.is_admin">Admin Panel</div>
+<div spry-else-if="this.is_moderator">Moderator Panel</div>
+<div spry-else>User Panel</div>
+```
+
+### Loop Rendering with `spry-per-*`
+
+Use `spry-per-{varname}="expression"` to iterate over collections:
+
+```html
+<div spry-per-task="this.tasks">
+    <span content-expr="task.name"></span>
+</div>
+```
+
+The variable name after `spry-per-` (e.g., `task`) becomes available in nested expressions.
+
+### Static Resources with `spry-res`
+
+Use `spry-res` to reference Spry's built-in static resources:
+
+```html
+<script spry-res="htmx.js"></script>
+<script spry-res="htmx-sse.js"></script>
+```
+
+This resolves to `/_spry/res/{resource-name}` automatically.
+
 ## IoC Composition
 
 ### Registration Patterns

+ 49 - 31
src/Component.vala

@@ -75,15 +75,15 @@ namespace Spry {
             return _instance;
         }}
 
-        protected MarkupNodeList get_elements_by_class_name(string class_name) {
+        protected Enumerable<MarkupNode> get_elements_by_class_name(string class_name) {
             return instance.get_elements_by_class_name(class_name);
         }
 
-        protected MarkupNodeList get_elements_by_tag_name(string tag_name) {
+        protected Enumerable<MarkupNode> get_elements_by_tag_name(string tag_name) {
             return instance.get_elements_by_tag_name(tag_name);
         }
 
-        protected new MarkupNodeList query(string xpath) {
+        protected new Enumerable<MarkupNode> query(string xpath) {
             return instance.select(xpath);
         }
 
@@ -313,44 +313,62 @@ namespace Spry {
         }
 
         private void transform_if_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
-            var nodes = doc.select("//spry-if");
-            foreach (var node in nodes) {
-                var expression_string = node.get_attribute("spry-if");
+            var root = new PropertyDictionary();
+            root["this"] = new NativeElement<Component>(this);
+            var evaluation_context = context ?? new EvaluationContext(root);
 
-                var root = new PropertyDictionary();
-                root["this"] = new NativeElement<Component>(this);
-                var evaluation_context = context ?? new EvaluationContext(root);
-                var expression = ExpressionParser.parse(expression_string);
-                var result = expression.evaluate(evaluation_context);
-
-                bool boolean_result;
-                if(result.is<bool>()) {
-                    boolean_result = result.as<bool>();
-                }
-                else if(result.is<int>()) {
-                    boolean_result = result.as<int>() != 0;
-                }
-                else {
-                    boolean_result = !result.is_null();
+            MarkupNode node;
+            // Select one by one, so we don't have problems with nesting
+            while((node = doc.select_one("//*[@spry-if or @spry-else-if or @spry-else]")) != null) {
+                var expression_string = node.get_attribute("spry-if") ?? node.get_attribute("spry-else-if");
+                node.remove_attribute("spry-if");
+                node.remove_attribute("spry-else-if");
+                node.remove_attribute("spry-else");
+                if(expression_string == null) {
+                    // else case
+                    continue;
                 }
 
-                if(boolean_result) {
-                    node.set_attribute("spry-hidden", "");
-                }
-                else {
-                    node.remove_attribute("spry-hidden");
+                var result = evaluate_if_expression(evaluation_context, expression_string, node);
+                if(result) {
+                    // Hide any chained nodes
+                    MarkupNode chained_node;
+                    while((chained_node = node.next_element_sibling) != null) {
+                        if(chained_node.get_attribute("spry-else") != null || chained_node.get_attribute("spry-else-if") != null) {
+                            chained_node.remove();
+                        }
+                        else {
+                            // If a sibling has no spry-else or spry-else-if it breaks the chain
+                            break;
+                        }
+                    }
                 }
             }
         }
 
+        private bool evaluate_if_expression(EvaluationContext context, string expression_string, MarkupNode node) throws Error {
+            var expression = ExpressionParser.parse(expression_string);
+            var result = expression.evaluate(context);
+
+            bool boolean_result;
+            if(result.is<bool>()) {
+                boolean_result = result.as<bool>();
+            }
+            else if(result.is<int>()) {
+                boolean_result = result.as<int>() != 0;
+            }
+            else {
+                boolean_result = !result.is_null();
+            }
+
+            return boolean_result;
+        }
+
         private async void transform_per_attributes(MarkupDocument doc, EvaluationContext? context = null) throws Error {
             MarkupNode node;
             // Select one by one, so we don't have problems with nesting
-            while((node = doc.select("//*[@*]").first_or_default(n => n.get_attributes().any(a => a.key.has_prefix("spry-per-")))) != null) {
-                var attribute = node.get_attributes().first_or_default(s => s.key.has_prefix("spry-per-"));
-                if(attribute == null) {
-                    continue;
-                }
+            while((node = doc.nodes.first_or_default(n => n.get_attributes().any(a => a.key.has_prefix("spry-per-")))) != null) {
+                var attribute = node.get_attributes().first(s => s.key.has_prefix("spry-per-"));
 
                 var root = new PropertyDictionary();
                 root["this"] = new NativeElement<Component>(this);