Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions collagraph/fragment.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,26 @@ def anchor(self) -> Any | None:
"""
parent = self.parent
assert parent is not None
idx = parent.children.index(self)
length = len(parent.children) - 1
while 0 <= idx < length:
idx += 1
if element := parent.children[idx].first():
return element

# Check if this fragment is in parent's children
if self in parent.children:
idx = parent.children.index(self)
length = len(parent.children) - 1
while 0 <= idx < length:
idx += 1
if element := parent.children[idx].first():
return element
# Fragment might be slot content - check parent's slot_contents if it's
# a ComponentFragment
elif isinstance(parent, ComponentFragment) and self in parent.slot_contents:
idx = parent.slot_contents.index(self)
length = len(parent.slot_contents) - 1
while 0 <= idx < length:
idx += 1
if element := parent.slot_contents[idx].first():
return element

return None

def set_attribute(self, attr: str, value: Any):
"""
Expand Down
36 changes: 27 additions & 9 deletions collagraph/sfc/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,12 @@ def create_children(
)
)
)

# Set slot_name on the ListFragment if parent is a component
if parent_node := (child.parent and child.parent()):
if is_component_tag(parent_node.tag, names):
result.append(ast_set_slot_name(name, "default"))

break

if node_with_list_expression:
Expand Down Expand Up @@ -760,12 +766,12 @@ def create_children(
# Check if we need to mark the item as content for the default slot
if not added_slot_name:
if parent_node := (child.parent and child.parent()):
# This assumes that the parent_node is a component if it starts
# with an uppercase character
# TODO: come up with a more solid solution for figuring out
# whether the parent is a component
if parent_node.tag and parent_node.tag[0].isupper():
attributes.append(ast_set_slot_name(el, "default"))
if is_component_tag(parent_node.tag, names):
# If there's a control flow wrapper (v-if/v-else-if/v-else),
# set slot_name on the control flow fragment since that's
# what gets registered in slot_contents
slot_target = control_flow_parent if control_flow_parent else el
attributes.append(ast_set_slot_name(slot_target, "default"))

# Create the appropriate fragment type
if is_dynamic_component:
Expand All @@ -790,9 +796,7 @@ def create_children(

# Create regular Fragment or ComponentFragment
is_component = (
(child.tag in names and not is_loop_variable)
or child.tag[0].isupper()
or "." in child.tag
is_component_tag(child.tag, names) and not is_loop_variable
)
result.append(
ast_create_fragment(
Expand Down Expand Up @@ -835,6 +839,20 @@ def is_directive(key):
return key.startswith((DIRECTIVE_PREFIX, ":", "@", "#"))


def is_component_tag(tag: str, names: set[str]) -> bool:
"""
Determine if a tag represents a component.

A tag is considered a component if:
- It matches an imported name or defined class (in `names`)
- It starts with an uppercase letter (Vue/JSX convention)
- It contains a dot (attribute access like `module.Component`)
"""
if not tag:
return False
return tag in names or tag[0].isupper() or "." in tag


def targets_for_list_expression(targets: ast.Name | ast.Tuple) -> set[str]:
def get_names(value, names):
if isinstance(value, ast.Name):
Expand Down
12 changes: 12 additions & 0 deletions tests/data/slots/dynamic_component.cgx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Node>
<component :is="component_type" :value="value" />
</Node>

<script lang="python">
import collagraph as cg
from tests.data.slots.node import Node


class Tree(cg.Component):
pass
</script>
2 changes: 0 additions & 2 deletions tests/data/slots/dynamic_if.cgx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<Node>
<!-- dynamic slot content needs to be enclosed in a template
tag, so this is not going to work. -->
<content v-if="show_content" />
</Node>

Expand Down
186 changes: 181 additions & 5 deletions tests/test_slots_dynamic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import pytest
from observ import reactive

from collagraph import Collagraph, EventLoopType
Expand Down Expand Up @@ -56,9 +55,7 @@ def test_slots_dynamic_for_template():
assert "children" not in root, format_dict(root)


@pytest.mark.xfail
def test_slots_dynamic_if():
# For a workaround: see test_slots_dynamic_if_template
from tests.data.slots.dynamic_if import Tree

state = reactive({"show_content": False})
Expand All @@ -81,9 +78,7 @@ def test_slots_dynamic_if():
assert "children" not in root, format_dict


@pytest.mark.xfail
def test_slots_dynamic_for():
# For a workaround: see test_slots_dynamic_for_template
from tests.data.slots.dynamic_for import Tree

state = reactive({"content": ["a", "b"]})
Expand All @@ -108,3 +103,184 @@ def test_slots_dynamic_for():
state["content"] = []

assert "children" not in root, format_dict(root)


def test_slots_dynamic_component():
from tests.data.slots.dynamic_component import Tree

state = reactive({"component_type": "foo", "value": "initial"})

gui = Collagraph(DictRenderer(), event_loop_type=EventLoopType.SYNC)
container = {"type": "container"}
gui.render(Tree, container, state)

root = container["children"][0]
assert root["type"] == "node"
assert "children" in root, format_dict(root)
assert len(root["children"]) == 1
assert root["children"][0]["type"] == "foo", format_dict(root)
assert root["children"][0]["attrs"]["value"] == "initial", format_dict(root)

# Change the dynamic component type
state["component_type"] = "bar"

assert len(root["children"]) == 1, format_dict(root)
assert root["children"][0]["type"] == "bar", format_dict(root)
assert root["children"][0]["attrs"]["value"] == "initial", format_dict(root)

# Change the bound attribute
state["value"] = "updated"

assert root["children"][0]["attrs"]["value"] == "updated", format_dict(root)


def test_slots_lowercase_component_alias_with_vif(parse_source):
"""
Test that v-if works in slots when the parent component is imported
with a lowercase alias. This would fail if component detection only
checked for uppercase first letter.
"""

# Use lowercase alias 'node' for the Node component with v-if in slot
# The 'node' alias is imported in the script, so it's in the 'names' set
App, _ = parse_source(
"""
<node>
<content v-if="show" />
</node>

<script>
from collagraph import Component
from tests.data.slots.node import Node as node

class App(Component):
pass
</script>
"""
)

state = reactive({"show": False})
gui = Collagraph(DictRenderer(), event_loop_type=EventLoopType.SYNC)
container = {"type": "container"}
gui.render(App, container, state)

root = container["children"][0]
assert root["type"] == "node"
assert "children" not in root, format_dict(root)

# Toggle v-if condition
state["show"] = True

assert "children" in root, format_dict(root)
assert len(root["children"]) == 1
assert root["children"][0]["type"] == "content"

state["show"] = False

assert "children" not in root, format_dict(root)


def test_slots_lowercase_component_alias_with_vfor(parse_source):
"""
Test that v-for works in slots when the parent component is imported
with a lowercase alias. This would fail if component detection only
checked for uppercase first letter.
"""

# Use lowercase alias 'node' for the Node component with v-for in slot
# The 'node' alias is imported in the script, so it's in the 'names' set
App, _ = parse_source(
"""
<node>
<item v-for="x in items" :value="x" />
</node>

<script>
from collagraph import Component
from tests.data.slots.node import Node as node

class App(Component):
pass
</script>
"""
)

state = reactive({"items": ["a", "b"]})
gui = Collagraph(DictRenderer(), event_loop_type=EventLoopType.SYNC)
container = {"type": "container"}
gui.render(App, container, state)

root = container["children"][0]
assert root["type"] == "node"
assert "children" in root, format_dict(root)
assert len(root["children"]) == 2

# Add item
state["items"].append("c")
assert len(root["children"]) == 3, format_dict(root)

# Remove items
state["items"] = []
assert "children" not in root, format_dict(root)


def test_slots_dotted_component_with_vif(parse_source):
"""
Test that v-if works in slots when the parent component uses
dot notation (e.g., module.Component). This would fail if component
detection only checked for uppercase first letter.
"""
# Create a simple module-like namespace with a component
wrapper, _ = parse_source(
"""
<wrapper>
<slot />
</wrapper>

<script>
from collagraph import Component

class Wrapper(Component):
pass
</script>
"""
)

# Create a simple class to act as a module namespace
class components: # noqa: N801
pass

components.wrap = wrapper

# Use dot notation for the component
App, _ = parse_source(
"""
<components.wrap>
<content v-if="show" />
</components.wrap>

<script>
from collagraph import Component

class App(Component):
pass
</script>
""",
namespace={"components": components},
)

state = reactive({"show": False})
gui = Collagraph(DictRenderer(), event_loop_type=EventLoopType.SYNC)
container = {"type": "container"}
gui.render(App, container, state)

root = container["children"][0]
assert root["type"] == "wrapper"
assert "children" not in root, format_dict(root)

# Toggle v-if condition
state["show"] = True

assert "children" in root, format_dict(root)
assert len(root["children"]) == 1
assert root["children"][0]["type"] == "content"