feat: slot/children composition for components

- AST: added Expr::Slot variant
- Parser: 'slot' keyword renders children, [...] bracket children after ComponentUse
- Codegen: DS_{name}(props, __children) factory pattern
- Type checker: Slot => Type::View
- Updated Card component with slot for children
- Added examples/slot-demo.ds
This commit is contained in:
enzotar 2026-02-26 16:14:35 -08:00
parent bb65e10f5c
commit 76bb1bb3a2
6 changed files with 79 additions and 6 deletions

View file

@ -464,7 +464,7 @@ impl JsEmitter {
}
fn emit_component_decl(&mut self, comp: &ComponentDecl, graph: &SignalGraph) {
self.emit_line(&format!("function DS_{}(props) {{", comp.name));
self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name));
self.indent += 1;
// Destructure props into local signal-compatible variables
// Props may be raw values or signals — wrap in a signal-like accessor
@ -891,13 +891,30 @@ impl JsEmitter {
container_var
}
// Component usage: `Button { label: "hello" }`
Expr::ComponentUse { name, props, children: _ } => {
// Component usage: `Button { label: "hello" }` or `Card { title: "x" } [ children ]`
Expr::ComponentUse { name, props, children } => {
let node_var = self.next_node_id();
let props_js: Vec<String> = props.iter()
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
.collect();
self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", ")));
if children.is_empty() {
self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", ")));
} else {
// Build children factory function
let children_fn = self.next_node_id();
self.emit_line(&format!("function {}() {{", children_fn));
self.indent += 1;
let container = self.next_node_id();
self.emit_line(&format!("const {} = document.createDocumentFragment();", container));
for child in children {
let child_var = self.emit_view_expr(child, graph);
self.emit_line(&format!("{}.appendChild({});", container, child_var));
}
self.emit_line(&format!("return {};", container));
self.indent -= 1;
self.emit_line("}");
self.emit_line(&format!("const {} = DS_{}({{ {} }}, {});", node_var, name, props_js.join(", "), children_fn));
}
node_var
}
@ -929,6 +946,16 @@ impl JsEmitter {
}
// Slot: render children passed to this component
Expr::Slot => {
let node_var = self.next_node_id();
self.emit_line(&format!(
"const {} = __children ? __children() : document.createComment('empty-slot');",
node_var
));
node_var
}
// Fallback: just create a text node
_ => {
let node_var = self.next_node_id();

View file

@ -304,6 +304,8 @@ pub enum Expr {
},
/// Index access: `grid[i]`, `pads[8 + i]`
Index(Box<Expr>, Box<Expr>),
/// Slot: renders children passed to a component
Slot,
}
/// String literal with interpolation segments.

View file

@ -1169,6 +1169,11 @@ impl Parser {
let name = name.clone();
self.advance();
// Slot keyword: renders children inside a component
if name == "slot" {
return Ok(Expr::Slot);
}
// Component use: `Button { label: "hello" }` — capitalized name + `{`
if name.chars().next().map_or(false, |c| c.is_uppercase())
&& self.check(&TokenKind::LBrace)
@ -1197,6 +1202,18 @@ impl Parser {
self.skip_newlines();
}
self.expect(&TokenKind::RBrace)?;
// Check for [...] children block after props
self.skip_newlines();
if self.check(&TokenKind::LBracket) {
self.advance(); // consume [
self.skip_newlines();
while !self.check(&TokenKind::RBracket) && !self.is_at_end() {
self.skip_newlines();
children.push(self.parse_expr()?);
self.skip_newlines();
}
self.expect(&TokenKind::RBracket)?;
}
return Ok(Expr::ComponentUse { name, props, children });
}

View file

@ -697,6 +697,8 @@ impl TypeChecker {
_ => self.fresh_tv(),
}
}
Expr::Slot => Type::View,
}
}

24
examples/slot-demo.ds Normal file
View file

@ -0,0 +1,24 @@
-- DreamStack Slot Composition Demo
-- Demonstrates component children/slot rendering
import { Card } from "../registry/components/card"
import { Button } from "../registry/components/button"
import { Badge } from "../registry/components/badge"
let count = 0
view main = column [
text "🧩 Slot Composition" { variant: "title" }
text "Components can now accept children!" { variant: "subtitle" }
Card { title: "Counter Card", subtitle: "with interactive children" } [
text "Count: {count}"
button "+1" { click: count += 1, variant: "primary" }
button "-1" { click: count -= 1, variant: "secondary" }
]
Card { title: "Nested Components", subtitle: "badge inside card" } [
Badge { label: "nested", variant: "success" }
text "Badge rendered inside Card slot"
]
]

View file

@ -1,8 +1,9 @@
-- DreamStack Card Component
-- Glassmorphism container with title and subtitle
-- DreamStack Card Component with Slot
-- Glassmorphism container with title, subtitle, and slot for children
export component Card(title, subtitle) =
column [
text title { variant: "title" }
text subtitle { variant: "subtitle" }
slot
] { variant: "card" }