ripple 0.2.108 → 0.2.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.108",
6
+ "version": "0.2.109",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -178,10 +178,9 @@ const visitors = {
178
178
  add_ripple_internal_import(context);
179
179
  return b.call('_$_.get', build_getter(node, context));
180
180
  }
181
-
182
- add_ripple_internal_import(context);
183
- return build_getter(node, context);
184
181
  }
182
+ add_ripple_internal_import(context);
183
+ return build_getter(node, context);
185
184
  }
186
185
  }
187
186
  },
@@ -2,10 +2,12 @@
2
2
 
3
3
  import { branch, destroy_block, render } from './blocks.js';
4
4
  import { COMPOSITE_BLOCK } from './constants.js';
5
+ import { apply_element_spread } from './render';
5
6
  import { active_block } from './runtime.js';
6
7
 
7
8
  /**
8
- * @param {() => (anchor: Node, props: Record<string, any>, block: Block | null) => void} get_component
9
+ * @typedef {((anchor: Node, props: Record<string, any>, block: Block | null) => void)} ComponentFunction
10
+ * @param {() => ComponentFunction | keyof HTMLElementTagNameMap} get_component
9
11
  * @param {Node} node
10
12
  * @param {Record<string, any>} props
11
13
  * @returns {void}
@@ -23,9 +25,39 @@ export function composite(get_component, node, props) {
23
25
  b = null;
24
26
  }
25
27
 
26
- b = branch(() => {
27
- var block = active_block;
28
- component(anchor, props, block);
29
- });
28
+ if (typeof component === 'function') {
29
+ // Handle as regular component
30
+ b = branch(() => {
31
+ var block = active_block;
32
+ /** @type {ComponentFunction} */ (component)(anchor, props, block);
33
+ });
34
+ } else {
35
+ // Custom element
36
+ b = branch(() => {
37
+ var block = /** @type {Block} */ (active_block);
38
+
39
+ var element = document.createElement(
40
+ /** @type {keyof HTMLElementTagNameMap} */ (component),
41
+ );
42
+ /** @type {ChildNode} */ (anchor).before(element);
43
+
44
+ if (block.s === null) {
45
+ block.s = {
46
+ start: element,
47
+ end: element,
48
+ };
49
+ }
50
+
51
+ const spread_fn = apply_element_spread(element, () => props || {});
52
+ spread_fn();
53
+
54
+ if (typeof props?.children === 'function') {
55
+ var child_anchor = document.createComment('');
56
+ element.appendChild(child_anchor);
57
+
58
+ props?.children?.(child_anchor, {}, block);
59
+ }
60
+ });
61
+ }
30
62
  }, COMPOSITE_BLOCK);
31
63
  }
@@ -128,39 +128,66 @@ export function apply_styles(element, newStyles) {
128
128
  * @returns {void}
129
129
  */
130
130
  export function set_attributes(element, attributes) {
131
+ let found_enumerable_keys = false;
132
+
131
133
  for (const key in attributes) {
132
134
  if (key === 'children') continue;
135
+ found_enumerable_keys = true;
133
136
 
134
137
  let value = attributes[key];
135
-
136
138
  if (is_tracked_object(value)) {
137
139
  value = get(value);
138
140
  }
141
+ set_attribute_helper(element, key, value);
142
+ }
139
143
 
140
- if (key === 'class') {
141
- const is_html = element.namespaceURI === 'http://www.w3.org/1999/xhtml';
142
- set_class(/** @type {HTMLElement} */ (element), value, undefined, is_html);
143
- } else if (key === '#class') {
144
- // Special case for static class when spreading props
145
- element.classList.add(value);
146
- } else if (is_event_attribute(key)) {
147
- // Handle event handlers in spread props
148
- const event_name = get_attribute_event_name(key);
149
-
150
- if (is_delegated(event_name)) {
151
- // Use delegation for delegated events
152
- /** @type {any} */ (element)['__' + event_name] = value;
153
- delegate([event_name]);
154
- } else {
155
- // Use addEventListener for non-delegated events
156
- event(event_name, element, value);
144
+ // Only if no enumerable keys but attributes object exists
145
+ // This handles spread_props Proxy objects from dynamic elements with {...spread}
146
+ if (!found_enumerable_keys && attributes) {
147
+ const allKeys = Reflect.ownKeys(attributes);
148
+ for (const key of allKeys) {
149
+ if (key === 'children') continue;
150
+ if (typeof key === 'symbol') continue; // Skip symbols - handled by apply_element_spread
151
+
152
+ let value = attributes[key];
153
+ if (is_tracked_object(value)) {
154
+ value = get(value);
157
155
  }
158
- } else {
159
- set_attribute(element, key, value);
156
+ set_attribute_helper(element, key, value);
160
157
  }
161
158
  }
162
159
  }
163
160
 
161
+ /**
162
+ * Helper function to set a single attribute
163
+ * @param {Element} element
164
+ * @param {string} key
165
+ * @param {any} value
166
+ */
167
+ function set_attribute_helper(element, key, value) {
168
+ if (key === 'class') {
169
+ const is_html = element.namespaceURI === 'http://www.w3.org/1999/xhtml';
170
+ set_class(/** @type {HTMLElement} */ (element), value, undefined, is_html);
171
+ } else if (key === '#class') {
172
+ // Special case for static class when spreading props
173
+ element.classList.add(value);
174
+ } else if (typeof key === 'string' && is_event_attribute(key)) {
175
+ // Handle event handlers in spread props
176
+ const event_name = get_attribute_event_name(key);
177
+
178
+ if (is_delegated(event_name)) {
179
+ // Use delegation for delegated events
180
+ /** @type {any} */ (element)['__' + event_name] = value;
181
+ delegate([event_name]);
182
+ } else {
183
+ // Use addEventListener for non-delegated events
184
+ event(event_name, element, value);
185
+ }
186
+ } else {
187
+ set_attribute(element, key, value);
188
+ }
189
+ }
190
+
164
191
  /**
165
192
  * @param {import('clsx').ClassValue} value
166
193
  * @param {string} [hash]
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mount, flushSync, track, createRefKey } from 'ripple';
3
+
4
+ describe('dynamic DOM elements', () => {
5
+ let container;
6
+
7
+ function render(component) {
8
+ mount(component, {
9
+ target: container,
10
+ });
11
+ }
12
+
13
+ beforeEach(() => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ });
17
+ afterEach(() => {
18
+ document.body.removeChild(container);
19
+ container = null;
20
+ });
21
+ it('renders static dynamic element', () => {
22
+ component App() {
23
+ let tag = track('div');
24
+
25
+ <@tag>{'Hello World'}</@tag>
26
+ }
27
+ render(App);
28
+
29
+ const element = container.querySelector('div');
30
+ expect(element).toBeTruthy();
31
+ expect(element.textContent).toBe('Hello World');
32
+ });
33
+ it('renders reactive dynamic element', () => {
34
+ component App() {
35
+ let tag = track('div');
36
+
37
+ <button onClick={() => {
38
+ @tag = 'span';
39
+ }}>{'Change Tag'}</button>
40
+ <@tag id="dynamic">{'Hello World'}</@tag>
41
+ }
42
+ render(App);
43
+ // Initially should be a div
44
+ let dynamicElement = container.querySelector('#dynamic');
45
+ expect(dynamicElement.tagName).toBe('DIV');
46
+ expect(dynamicElement.textContent).toBe('Hello World');
47
+ // Click button to change tag
48
+ const button = container.querySelector('button');
49
+ button.click();
50
+ flushSync();
51
+ // Should now be a span
52
+ dynamicElement = container.querySelector('#dynamic');
53
+ expect(dynamicElement.tagName).toBe('SPAN');
54
+ expect(dynamicElement.textContent).toBe('Hello World');
55
+ });
56
+ it('renders self-closing dynamic element', () => {
57
+ component App() {
58
+ let tag = track('input');
59
+
60
+ <@tag type="text" value="test" />
61
+ }
62
+ render(App);
63
+
64
+ const element = container.querySelector('input');
65
+ expect(element).toBeTruthy();
66
+ expect(element.type).toBe('text');
67
+ expect(element.value).toBe('test');
68
+ });
69
+ it('handles dynamic element with attributes', () => {
70
+ component App() {
71
+ let tag = track('div');
72
+ let className = track('test-class');
73
+
74
+ <@tag class={@className} id="test" data-testid="dynamic-element">{'Content'}</@tag>
75
+ }
76
+ render(App);
77
+
78
+ const element = container.querySelector('#test');
79
+ expect(element.tagName).toBe('DIV');
80
+ expect(element.className).toBe('test-class');
81
+ expect(element.getAttribute('data-testid')).toBe('dynamic-element');
82
+ expect(element.textContent).toBe('Content');
83
+ });
84
+ it('handles nested dynamic elements', () => {
85
+ component App() {
86
+ let outerTag = track('div');
87
+ let innerTag = track('span');
88
+
89
+ <@outerTag class="outer">
90
+ <@innerTag class="inner">{'Nested content'}</@innerTag>
91
+ </@outerTag>
92
+ }
93
+ render(App);
94
+
95
+ const outer = container.querySelector('.outer');
96
+ const inner = container.querySelector('.inner');
97
+
98
+ expect(outer.tagName).toBe('DIV');
99
+ expect(inner.tagName).toBe('SPAN');
100
+ expect(inner.textContent).toBe('Nested content');
101
+ expect(outer.contains(inner)).toBe(true);
102
+ });
103
+ it('handles dynamic element with class object', () => {
104
+ component App() {
105
+ let tag = track('div');
106
+ let active = track(true);
107
+
108
+ <@tag class={{ active: @active, 'dynamic-element': true }}>
109
+ {'Element with class object'}
110
+ </@tag>
111
+ }
112
+ render(App);
113
+
114
+ const element = container.querySelector('div');
115
+ expect(element).toBeTruthy();
116
+ expect(element.classList.contains('active')).toBe(true);
117
+ expect(element.classList.contains('dynamic-element')).toBe(true);
118
+ });
119
+ it('handles dynamic element with style object', () => {
120
+ component App() {
121
+ let tag = track('span');
122
+
123
+ <@tag style={{
124
+ color: 'red',
125
+ fontSize: '16px',
126
+ fontWeight: 'bold'
127
+ }}>
128
+ {'Styled dynamic element'}
129
+ </@tag>
130
+ }
131
+ render(App);
132
+
133
+ const element = container.querySelector('span');
134
+ expect(element).toBeTruthy();
135
+ expect(element.style.color).toBe('red');
136
+ expect(element.style.fontSize).toBe('16px');
137
+ expect(element.style.fontWeight).toBe('bold');
138
+ });
139
+ it('handles dynamic element with spread attributes', () => {
140
+ component App() {
141
+ let tag = track('section');
142
+ const attrs = {
143
+ id: 'spread-section',
144
+ 'data-testid': 'spread-test',
145
+ class: 'spread-class',
146
+ };
147
+
148
+ <@tag {...attrs} data-extra="additional">
149
+ {'Element with spread attributes'}
150
+ </@tag>
151
+ }
152
+ render(App);
153
+
154
+ const element = container.querySelector('section');
155
+ expect(element).toBeTruthy();
156
+ expect(element.id).toBe('spread-section');
157
+ expect(element.getAttribute('data-testid')).toBe('spread-test');
158
+ expect(element.className).toBe('spread-class');
159
+ expect(element.getAttribute('data-extra')).toBe('additional');
160
+ });
161
+ it('handles dynamic element with ref', () => {
162
+ let capturedElement = null;
163
+
164
+ component App() {
165
+ let tag = track('article');
166
+
167
+ <@tag {ref (node) => { capturedElement = node; }} id="ref-test">
168
+ {'Element with ref'}
169
+ </@tag>
170
+ }
171
+ render(App);
172
+ flushSync();
173
+ expect(capturedElement).toBeTruthy();
174
+ expect(capturedElement.tagName).toBe('ARTICLE');
175
+ expect(capturedElement.id).toBe('ref-test');
176
+ expect(capturedElement.textContent).toBe('Element with ref');
177
+ });
178
+ it('handles dynamic element with createRefKey in spread', () => {
179
+ component App() {
180
+ let tag = track('header');
181
+
182
+ function elementRef(node) {
183
+ // Set an attribute on the element to prove ref was called
184
+ node.setAttribute('data-spread-ref-called', 'true');
185
+ node.setAttribute('data-spread-ref-tag', node.tagName.toLowerCase());
186
+ }
187
+
188
+ const dynamicProps = {
189
+ id: 'spread-ref-test',
190
+ class: 'ref-element',
191
+ [createRefKey()]: elementRef
192
+ };
193
+
194
+ <@tag {...dynamicProps}>{'Element with spread ref'}</@tag>
195
+ }
196
+ render(App);
197
+ flushSync();
198
+
199
+ // Check that the spread ref was called by verifying attributes were set
200
+ const element = container.querySelector('header');
201
+ expect(element).toBeTruthy();
202
+ expect(element.getAttribute('data-spread-ref-called')).toBe('true');
203
+ expect(element.getAttribute('data-spread-ref-tag')).toBe('header');
204
+ expect(element.id).toBe('spread-ref-test');
205
+ expect(element.className).toBe('ref-element');
206
+ });
207
+ });